不少团队知道 MyBatis 推荐优先使用 #{},却未必能准确说清 ${} 为什么会把风险直接带进生产环境。真正需要搞明白的,不只是“它会不会注入”,而是它在 SQL 生成链路里究竟做了什么、哪些看起来正常的动态写法最容易失守,以及上线前该靠什么办法把这类问题找出来。
SQL 构建阶段就已被拼接,预编译保护失效
${} 本质上是纯字符串替换。MyBatis 在解析动态 SQL 时,会直接把变量值原样插入 SQL 文本,再把最终结果交给 Statement 执行,而不是交给 JDBC 的 PreparedStatement 去做参数绑定。

这一步的区别非常关键:一旦使用 ${},用户输入就不再是“参数值”,而是直接变成 SQL 语法的一部分。数据库收到的不是带占位符的语句,而是一条已经拼好的完整 SQL,因此预编译隔离、参数转义和类型处理都不会生效。
例如传入:
username = "admin' OR '1'='1"
如果 SQL 写成:
WHERE username = '${username}'
最终执行结果就是:
SELECT * FROM user WHERE username = 'admin' OR '1'='1'
这里的条件恒为真,认证、查询限制甚至部分数据隔离逻辑都可能被直接绕过。问题不在于语句写得复杂,而在于输入内容已经进入 SQL 语法层,数据库会把它当成真实条件执行。
不转义、不加类型约束,注入面会继续扩大
很多人把风险理解成“单引号没处理好”,但 ${} 的问题远不止这一层。它替换后的内容不会自动加引号,不做空值处理,也没有类型约束,等于把一段完全不受控的文本直接塞进 SQL。
这意味着攻击者能构造的不只是恒真条件,还可能包括分号、注释符、子查询或耗时表达式。例如:
ORDER BY ${column}+ 输入id; DROP TABLE log --:在某些数据库驱动允许多语句执行时,可能触发破坏性操作。ORDER BY ${column}+ 输入id, (SELECT SLEEP(5)):即使拿不到数据,也可能借助耗时函数拖慢接口响应,形成拒绝服务效果。- 只靠前端 JavaScript 过滤同样不可靠:只要后端没有做校验,十六进制编码、双写关键字等绕过方式依然能把恶意内容送进 SQL。
也就是说,${} 不只是“比 #{} 稍微危险一点”,而是直接放弃了参数边界。只要变量来源不可信,攻击面就不再局限于某一个字段,而会扩散到整条 SQL 的结构本身。
这些动态写法最容易埋雷
业务里最麻烦的地方在于,很多 ${} 看起来像是在“做动态 SQL”,甚至配合了 <if> 判断,乍看还挺合理。但只要实际拼接点仍然是 ${},安全性就没有建立起来。

几个常见高危场景尤其值得优先排查:
WHERE ${condition}:把整段条件交给外部输入,本质上等于给了一个 SQL 片段入口,和开放 SQL 编辑器没有本质差别。AND ${sqlFragment}:问题比直接拼整句更隐蔽,审查代码时很容易误判成“只是补充条件”。LIKE '%${keyword}%':这是模糊查询里最常见的坑。很多人以为外层有引号和百分号就安全了,实际上只要${keyword}可控,注入照样成立,通配符还会放大查询范围。
这类写法往往分散在 XML 的多个分支里,人工肉眼很难一次看全。上线前如果不做一次全量检索,例如检查项目中所有 ${ 的使用位置,就很容易把问题代码带进生产。
真正要防的是失控变量,而不是语法本身
在真实项目里,${} 经常出现在 ORDER BY、表名、字段名这类无法直接用 #{} 绑定的位置。这里的难点不在于“能不能动态”,而在于动态内容是否可控。

如果变量来自 HTTP 请求、配置中心、MQ 消息,或者任何外部输入源,而代码里又没有白名单、枚举映射或严格校验,那么风险就已经具备落地条件。此时即便业务本意只是“支持排序字段切换”或“按表分片查询”,也仍然可能被利用。
更稳妥的思路是把所有 ${} 都视为高风险点,只允许它绑定到明确受控的值,例如:
- 排序字段只能从固定枚举中选择,而不是直接接收前端传入的列名;
- 表名、字段名只能来自后端维护的映射关系,不能透传用户输入;
- 所有使用
${}的地方都要能追溯参数来源,并有清晰的校验逻辑。
换句话说,真正难防的不是大家不知道该优先用 #{},而是不知道项目里还有哪些地方悄悄写了 ${},以及这些变量有没有被任何人直接改到。只要这两件事说不清,风险就不算真正收住。









