Java 代码审计实战速查(三):数据流追踪与过滤器判断

上一篇拿到了一张重点清单。这一篇讲怎么顺着清单往代码深处走。

追踪数据流本身不难,难的是两件事:跟到一半丢了,和看到一个过滤器但判断不出它到底挡不挡得住。这一篇主要解决这两个。


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') &gt;= 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、命令执行、表达式注入,它们本质是同一件事——可控数据进入了解释器。


暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇