上一篇拿到了一张重点清单。这一篇讲怎么顺着清单往代码深处走。
追踪数据流本身不难,难的是两件事:跟到一半丢了,和看到一个过滤器但判断不出它到底挡不挡得住。这一篇主要解决这两个。
0x01 两个方向,用途不同
从危险函数往上追。
先列出所有 Sink——能执行命令、能拼 SQL、能读写文件的函数——然后逐个往上找调用方,一直找到能被外部控制的入口。
适合"这套系统有没有这类问题"。覆盖全,但命中率低。一个 ${orderBy} 往上可能追出四五个调用方,只有一个是参数可控的。
从接口往下追。
拿一个具体接口,顺着 Service、DAO 一路往下,看参数最后落到哪里。
精确,但慢。而且如果只做这一个方向,你会漏掉那些"接口看起来无害、但内部转了几手最后进了危险函数"的情况。
两个方向必须交叉。 向上追出来的可疑点,要用向下追验证可控性;向下追的时候,要在每个 Sink 处回头确认"这个 Sink 有没有别的入口"。
0x02 跟丢的六个原因
跟丢不是因为你不够仔细,是因为 Java 的代码组织方式天然会打断调用链。常见的有六个。
接口加多实现。 contentService.query(map) 跟进去是一个 interface,实现类可能有三四个。IDEA 的 Ctrl+Alt+B 能列出所有实现,但你要判断的是哪个实现在运行时被注入——看 @Service 的名字和注入点的变量名。名字对不上,就得看 Spring 的配置或者 @Qualifier。
重载方法。 queryBySQL(String sql) 和 queryBySQL(String sql, Object... params),IDE 跳转经常跳错一个。看参数个数和类型对齐。
方法定义在父类里。 若依的 BaseController.startOrderBy() 就是典型:
public class SysUserController extends BaseController {
@GetMapping("/list")
public TableDataInfo list(SysUser user) {
startPage(); // ← 定义在 BaseController
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}
}
startPage() 在 BaseController 里,而它内部又调了 startOrderBy(),最终把 orderByColumn 和 isAsc 拼进 PageHelper.orderBy()。追 Controller 的时候必须养成习惯:看到不认识的方法名,先看是不是从父类继承的。
反射和动态代理。 Class.forName(beanName)、SpringUtils.getBean(name)、AOP 增强后的方法,静态追踪全都断在这里。这类只能靠运行时调试补。
MyBatis 的接口和 XML 不在一个文件里。 这是 Java 审计里最独特的一条,单独讲。
<sql> 片段脱离调用链。 同一条,单独讲。
0x03 MyBatis 让追踪变成双向的
Java 代码里,DAO 是一个接口:
// SysUserMapper.java
public List<SysUser> selectUserList(SysUser user);
实现在 XML 里,而且参数名对不上:
<!-- SysUserMapper.xml -->
<select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
<include refid="selectUserVo"/>
where u.del_flag = '0'
<if test="userName != null and userName != ''">
AND u.user_name like concat('%', #{userName}, '%')
</if>
<if test="params.beginTime != null and params.beginTime != ''">
and date_format(u.create_time,'%y%m%d') >= date_format(#{params.beginTime},'%y%m%d')
</if>
</select>
Java 侧传的是 SysUser 对象,XML 侧读的是 userName、params.beginTime。这两个东西的对应关系只存在于开发者的脑子里,IDE 跳不过去,只能人工对齐。
所以 MyBatis 的追踪要双向做。
从 XML 的 ${ 往上追。 全局搜 \$\{,每命中一个,记下语句的 id,回 DAO 接口找同名方法,再往上找调用方。
从 Service 往下追的时候,要额外检查两样东西:
一是 <include refid="...">。
<sql id="sqlWhere">
<if test="ew != null and ew.sqlSegment != null">${ew.sqlSegment}</if>
</sql>
<select id="queryExcludeApp" resultType="DictEntity">
select * from dict
<include refid="sqlWhere"/>
order by ${orderBy}
</select>
sqlWhere 这个片段本身不是一个 DAO 方法,你在 DAO 接口里搜不到它,IDE 的 Find Usages 在 XML 上也不一定准。它里面藏着 ${ew.sqlSegment} 这种拼接。想找到所有引用它的地方,只能全局搜 sqlWhere。
这是 MyBatis 审计里最容易漏的一类点:危险代码在 <sql> 片段里,而片段是共享的。
二是 <if> 里的动态条件。<if test="..."> 只决定这个条件拼不拼进去,不决定值安不安全。但如果条件本身写错了——比如判空之后没有 else,SQL 会变成 delete from t where id 这种残缺语句——那是另一个问题,后面讲逻辑漏洞时再说。
0x04 判断一个过滤器能不能绕
看到一个过滤,不要问"它是黑名单还是白名单",那个二分没有意义。要问下面五个问题。
问题一:过滤的是类型,还是字符
最强的是类型转换,因为它直接改变了数据的可能性空间。
Long id = Long.parseLong(request.getParameter("id"));
mapper.selectById(id);
这里 id 只可能是数字,无论外面传什么。这是真正的白名单。
但如果后面又转回字符串拼进去:
Long id = Long.parseLong(request.getParameter("id"));
String sql = "select * from t where id = " + id;
那类型转换就白做了——虽然从类型上仍然安全,但这种写法说明开发对边界没概念,旁边很可能有真正裸奔的地方。
判断顺序是:类型转换 > 枚举白名单 > 字符白名单 > 黑名单替换 > 无过滤。 看到前两种可以快速放过,看到后两种要细看。
问题二:是删掉字符,还是报错
删掉字符是最好绕的。
String safe = input.replace("select", "")
.replace("union", "")
.replace("and", "");
输入 selselectect,第一次 replace 把中间的 select 删掉,剩下的正好拼成 select。输入 ununionion 得到 union。anandd 得到 and。
只要过滤器做的是"删除",就要单独测一遍嵌套拼接。 而如果过滤失败时直接抛异常、拒绝整个请求,就没有这个问题。
if (!value.matches(SQL_PATTERN)) {
throw new UtilException("参数不符合规范,不能进行查询");
}
这个写法至少在这一点上是安全的。
问题三:白名单允许的字符集,能不能构造出有效载荷
这是这篇里最重要的一句话:白名单不等于安全。
若依的 SqlUtil 用了一个正则白名单来保护排序参数:
// ruoyi-common/src/main/java/com/ruoyi/common/utils/sql/SqlUtil.java
public static String SQL_PATTERN = "[a-zA-Z0-9_\\ \\,\\.]+";
public static String escapeOrderBySql(String value) {
if (StringUtils.isNotEmpty(value) && !isValidOrderBySql(value)) {
throw new UtilException("参数不符合规范,不能进行查询");
}
return value;
}
public static boolean isValidOrderBySql(String value) {
return value.matches(SQL_PATTERN);
}
调用点在 BaseController:
protected void startOrderBy() {
PageDomain pageDomain = TableSupport.buildPageRequest();
if (StringUtils.isNotEmpty(pageDomain.getOrderBy())) {
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
PageHelper.orderBy(orderBy);
}
}
而 PageDomain.getOrderBy() 是拼出来的:
public String getOrderBy() {
if (StringUtils.isEmpty(orderByColumn)) {
return "";
}
return StringUtils.toUnderScoreCase(orderByColumn) + " " + isAsc;
}
所以 orderByColumn=status 加上 isAsc=xxx,最终 PageHelper.orderBy("status xxx"),字符串原样拼到 ORDER BY 后面。
正则允许的字符是:字母、数字、下划线、空格、逗号、点。括号、引号、等号、比较符全部禁止。
看着很严。但 MySQL 里,构造一个有效载荷并不需要这些字符:
- 字符串可以用十六进制字面量,
0x61就是a,全是数字和字母 - 比较可以用
LIKE代替=,不需要等号 - 条件分支可以用
CASE WHEN ... THEN ... ELSE ... END,不需要括号和引号
于是 isAsc 传这个值,可以过正则:
, CASE WHEN u.user_id LIKE 2 THEN CASE WHEN u.password LIKE 0x24326124 THEN 0 ELSE 2 END ELSE 1 END
效果是控制排序结果:密码前缀猜中的目标用户排第一位,猜错的排最后,路人排中间。通过观察返回列表里第一行的 userId 是不是目标用户,就能逐字符把密码哈希盲注出来。 全程没有用一个被正则禁止的字符。
这是若依 4.8.1 上公开披露过的问题。
所以判断白名单的正确做法是:把这个白名单允许的字符集列出来,然后问"用这些字符能不能在目标语法里构造出一条有效指令"。 不能只看它是白名单就放过。
修复方式也很明确,把"接受 SQL 片段"改成"只接受枚举值":
public static boolean isValidOrderBySql(String value) {
return "asc".equalsIgnoreCase(value) || "desc".equalsIgnoreCase(value);
}
问题四:调用链上真的有这个过滤吗
上面那个 SqlUtil 里其实还有一个黑名单方法 filterKeyword。文件里存在这个方法,但 escapeOrderBySql 没有调用它。
看到过滤函数存在,不等于这条路径上调用了它。 必须确认调用点。这一步没什么技巧,就是 Ctrl+B 或者全局搜方法名,看调用链上有没有。
比"没调用"更隐蔽的情况是:过滤在,但过滤的入口和实际到达 Sink 的入口不是同一个。
MCMS 的一个案例是典型。它的 IDictDao.xml 里有一条 queryExcludeApp,引用了共享的 <sql id="sqlWhere"> 片段:
<sql id="sqlWhere">
<if test="el == 'eq'">${item.field} = #{item.value}</if>
...
</sql>
item.field 是直接拼接的。从 Controller 往下追的时候,看不到这个参数是怎么进来的——因为进来的方式不是普通的 @RequestParam,而是 @ModelAttribute:
@RequestMapping("/listExcludeApp")
public String listExcludeApp(@ModelAttribute DictEntity dictEntity, ...) { ... }
@ModelAttribute 会把请求参数按名字自动绑定到 DictEntity 的字段上。而 DictEntity 的父类里,getSqlWhere() 和 getSqlWhereList() 这两个 getter 会对字段做加工,加工结果最终流进了 <sql> 片段。
也就是说,过滤点或者加工点根本不在你追踪的那条 setter 链上,而是在 getter 里,由框架绑定机制触发。
请求变成这样就能打:
GET /listExcludeApp?sqlWhere=[{"field":"extractvalue(0x7e,concat(0x7e,(database())))","el":"eq"}]
追踪这类问题的检查项是:
- 用
@ModelAttribute、@RequestBody的实体类,父类和子类的 getter 都要看,加工逻辑经常藏在getXxx()里 - 实体类里有没有
sqlWhere、ew、params、condition这类"结构化条件"字段,它们一般会变成 SQL 片段 - 用 MyBatis-Plus 的
QueryWrapper/UpdateWrapper时同理,重点看有没有直接从请求参数构造 Wrapper
问题五:同一个 Sink,有几个入口
这是最容易出高危的地方。
@Service
public class OrderService {
public List<Order> query(Map<String, Object> params) {
return orderMapper.queryBySQL(params); // ← Sink
}
}
query() 有四个调用方。其中三个 Controller 都对参数做了严格校验,第四个是后来加的一个"导出"接口,什么都没做就直接把请求参数丢进来了。
审计时的动作是:每找到一个 Sink,先数一数它有几个调用方。 IDE 的 "Find Usages" 一次就能列出来,但很多人追到一个能走通的路径就停了,不会回头数。
上面若依那个 orderBy 也是同一个道理——startPage() 在很多 Controller 里被调用,只要有一个 Controller 的排序参数是从请求参数直接绑定到 PageDomain 的(若依默认就是这样),全站所有列表接口就都通了。
0x05 一次 14 个点的排查
有一回审计一套 MyBatis 系统,我全局搜 \$\{,命中 14 个拼接点。最后确认能走到可控入口的只有 3 个。
剩下 11 个的死因:
4 个 表名/列名是硬编码常量,参数只控制值,不控制结构
3 个 没有任何接口调用这条路(历史遗留的死代码)
2 个 参数在进入 XML 之前被 Integer 类型接收,无法传入字符串
1 个 过滤函数存在于同一个类里,但这条路径没有调用
1 个 参数确实可控,但 Sink 所在的方法只被另一个不可控的内部任务调用
花在这 11 个上的时间大概占总时间的六成,产出是零。
但这六成不是浪费。因为"排除了 11 个"这件事本身就是结论。 只有把旁边那些看起来一样、实际不可达的点逐个排掉,你才能对剩下那 3 个下判断说"这里确实有问题"。否则报告里写的每一条都带着"可能"两个字,甲方看完不知道该修哪个。
顺带说一个副产品:第 2 类(死代码)和第 5 类(不可达)其实值得单独记一笔。它们现在是安全的,但只要哪天有人加了个接口把它接上,就立刻变成漏洞。这类点我会在报告里写成"低风险,建议清理",而不是当成漏洞报。
0x06 清单
[ ] Sink 清单已列出(按危险函数搜)
[ ] 每个 Sink 用 Find Usages 数过调用方数量,不只追一条路
[ ] 向上追到入口后,确认参数是否真的由外部控制
[ ] 向下追时,检查方法是否继承自父类(BaseController 这类)
[ ] 接口方法用 Ctrl+Alt+B 确认运行时注入的是哪个实现
[ ] MyBatis:全局搜 \$\{,记录语句 id,回 DAO 找同名方法
[ ] MyBatis:全局搜 <include refid,检查被引用的 <sql> 片段里有没有拼接
[ ] MyBatis:单个条件是否被多个语句复用
[ ] 每个过滤器确认调用点,不只看函数是否存在
[ ] 过滤器是删除还是抛异常;删除的要做嵌套拼接测试
[ ] 白名单:列出允许字符集,推演能否构造有效载荷
[ ] 检查 @ModelAttribute / @RequestBody 实体的父类 getter
[ ] 检查实体里的 sqlWhere / ew / params / condition 类字段
[ ] 排不掉的点记录死因分类(不可控 / 不可达 / 死代码 / 无过滤需求)
下一篇进入具体漏洞,先从出现频率最高的讲:注入类的 SQL、命令执行、表达式注入,它们本质是同一件事——可控数据进入了解释器。