ElIauk

MyBatis #{} 与 ${}:SQL 注入与排序坑

一句话区别 MyBatis 里 #{} 和 ${} 长得很像,行为完全不同: #{} 会被编译成 ?,参数通过 PreparedStatement 传入,值永远只是值; ${} 在 SQL 拼接阶段就把字符串原样塞进去,参数会变成 SQL 语法的一部分。 前者能防注入,后者是 SQL 注入的入口。这

评论
MyBatis #{} 与 ${}:SQL 注入与排序坑

一句话区别

MyBatis 里 #{} 和 ${} 长得很像,行为完全不同:

  • #{} 会被编译成 ?,参数通过 PreparedStatement 传入,值永远只是值;
  • ${} 在 SQL 拼接阶段就把字符串原样塞进去,参数会变成 SQL 语法的一部分。

前者能防注入,后者是 SQL 注入的入口。这条结论要刻在脑子里:能用 #{} 的地方一律用 #{}。

#{} 能防注入的根本原因,是它把 SQL 的结构和参数分开了:SQL 先被数据库解析、生成执行计划,参数随后作为值传入,无论参数里有什么字符,都不会被当成语法。

${} 则是在 Java 侧先做字符串替换,替换后的整段文本才交给数据库解析。参数里只要带一个单引号,SQL 的结构就可能被改写。它不是“可能不安全”,而是“只要参数可控就一定不安全”。

还有一个容易忽略的差异:#{} 能自动处理类型转换和引号,字符串、日期、数字都能正确绑定;${} 全靠自己拼引号,日期格式、null 值、特殊字符都要手动处理,出错概率高得多。

所以判断标准很简单:这个位置需不需要 SQL 标识符(表名、列名、排序方向)?不需要就用 #{};需要就先过白名单,再用 ${}。

一个注入的反例

下面这段只用于说明原理,不要写进项目:

// ❌ 反例:字符串直接拼进 SQL
@Select("select id, name from user where name = '${name}'")
User selectByName(@Param("name") String name);

正常传 张三,SQL 是 where name = '张三',看起来没问题。但只要传 x' or '1'='1,拼出来的 SQL 就变成:

select id, name from user where name = 'x' or '1'='1'

条件永远成立,整张表的数据都会被查出来。如果这是登录接口,攻击者甚至能绕过密码校验。换回 #{},参数会被当成一个普通字符串,单引号也会被驱动转义,注入无从谈起。

排序为什么必须用 ${}

order by 后面跟的是列名,不是值。写成 order by #{sortBy},编译后是 order by ?,MySQL 会把它当成一个常量字符串,排序直接失效,有的版本还会报语法错误。

这就是 ${} 唯一合理的用武之地:拼表名、列名、排序方向这类SQL 结构。但前提是,拼进去的值必须来自白名单,不能来自用户输入:

private static final List<String> SORT_FIELDS = Arrays.asList("id", "name", "created_at");

public List<User> list(String sortBy) {
    if (!SORT_FIELDS.contains(sortBy)) {
        throw new IllegalArgumentException("不支持的排序字段: " + sortBy);
    }
    return userMapper.selectAllOrderBy(sortBy);
}
<select id="selectAllOrderBy" resultType="com.example.demo.entity.User">
    select id, name, created_at
    from user
    order by ${sortBy}
</select>

排序方向也一样处理,只允许 asc 或 desc,或者用枚举映射成固定的 SQL 片段。只要有一个入口能让前端把任意字符串传到 ${},前面所有的 #{} 都白用了。

like 和 in 的正确写法

模糊查询不要写 like '%${name}%',用 concat 配合 #{}:

<select id="selectByName" resultType="com.example.demo.entity.User">
    select id, name, created_at
    from user
    where name like concat('%', #{name}, '%')
</select>

in 查询用 <foreach>,不要用 ${} 把列表拼成字符串:

<select id="selectByIds" resultType="com.example.demo.entity.User">
    select id, name, created_at
    from user
    where id in
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

这样每个 ID 都是一个独立的 ? 占位符,参数个数由集合长度决定,既安全又不会因为某个 ID 带引号导致语法错误。

常见坑

1. 以为 #{} 哪儿都能用

表名、列名、order by、group by 这些位置不能用 #{},只能拼字符串。遇到这类需求,先想有没有别的方案(比如固定几种查询),实在要拼就加白名单。

2. 用 ${} 拼 like

like '%${keyword}%' 既可能注入,又会让 % 参与 SQL 解析。统一改成 concat。

3. 用 ${} 拼 in 列表

in (${ids}) 是另一个高频注入点,尤其当前端传的是字符串拼接后的 ID 列表时。改成 <foreach>,顺便还能避免空集合导致的语法错误。

4. 以为日志里能看到真实参数

#{} 在 SQL 日志里显示成 ?,看不到具体值。有人为了“方便排查”就把 SQL 改成 ${},这是拿安全换便利。需要看参数时调高日志级别、用 MyBatis 的日志实现或专门的 SQL 打印组件。

5. 动态 SQL 里混用两者

<if test="name != null">and name = '${name}'</if> 这种写法在代码评审里应该直接打回。动态条件只控制 SQL 片段是否存在,参数绑定仍然用 #{}。

排查步骤

  1. 全局搜索 ${,逐个确认拼接的值是不是来自用户输入;
  2. 来自前端、Header、URL 的一律改成 #{},确实要拼结构的加白名单;
  3. 打开 SQL 日志,看实际 SQL 里有没有出现单引号包裹的变量;
  4. 对排序、表名参数做一次手工测试,传 id desc、1=1 等异常值,确认被拦截;
  5. 代码评审把“是否使用 ${}”列为固定检查项,比事后扫描有效得多。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者