MyBatis的SQL映射文件里,${}和#{}是两种常用的参数占位符。很多人一开始会以为它们只是写法不同,实际上一个走字符串替换,一个走预编译参数绑定。这个差异直接决定了接口是否存在SQL注入风险。以一个最常见的登录查询为例,先看危险写法。

上面这段mapper里,username和password都用${}包着。MyBatis在处理这种写法时,会直接把变量值填进SQL字符串,再交给数据库执行。如果攻击者在用户名框输入admin' or '1'='1,最终SQL会变成SELECT * FROM sys_user WHERE username = 'admin' or '1'='1' AND password = ''。数据库会先执行or '1'='1这个恒真条件,后面的密码校验完全失效。把${}改成#{}以后,同样的输入只会被当作一个普通字符串参数绑定到?占位符上,单引号和or不再具备SQL语法含义。
<select id="findUserByLogin" parameterType="map" resultType="User">
SELECT * FROM sys_user
WHERE username = '${username}'
AND password = '${password}'
</select>
<select id="findUserByLogin" parameterType="map" resultType="User">
SELECT * FROM sys_user
WHERE username = #{username}
AND password = #{password}
</select>
一、两种占位符在MyBatis内部的处理差异
MyBatis解析<select>节点时,如果发现SQL里包含${},会先把变量值通过字符串拼接的方式塞进SQL里,然后再把这个已经固定下来的SQL交给JDBC执行。这意味着参数值会参与SQL语法解析。比如传入admin' or '1'='1,拼接后单引号出现的位置直接改变了原始SQL的条件结构。
而#{}的处理方式完全不同。MyBatis会先把SQL里的#{}替换成JDBC的?占位符,随后通过PreparedStatement的setString、setInt等方法把参数值绑定到对应位置。参数值只作为数据处理,不参与SQL语法解析。JDBC驱动在绑定字符串时会自动处理单引号等特殊字符,因此即使值里包含单引号,也不会截断原本的SQL字符串。
从日志里能更直观地看到这个过程。使用#{}时,MyBatis的输出大致是这样的:
==> Preparing: SELECT * FROM sys_user WHERE username = ? AND password = ? ==> Parameters: admin' or '1'='1(String), 123456(String)
SQL结构在准备阶段就已经固定,参数是后面单独传进去的。如果换成${},日志里打印的Preparing语句会直接包含攻击载荷,数据库根本不知道哪些是原始SQL、哪些是用户输入。
二、#{}为什么能阻断SQL注入
SQL注入的本质是用户输入被当成SQL代码执行。要形成注入,攻击者必须破坏原有SQL语句的语法边界,比如提前闭合字符串引号,或者追加新的条件、注释符。${}直接把值拼进SQL里,等于给攻击者提供了修改语法的机会。比如输入admin' --,单引号会提前闭合,随后--把后面的密码条件注释掉,整个查询就只剩下用户名条件。
#{}通过预编译参数绑定绕开了这个问题。数据库先解析一次带?的SQL模板,确定好查询哪些表、哪些字段、哪些条件,之后才接收实际参数。参数即使包含单引号、分号、注释符,也只会被当成一个字符串值。以MySQL驱动为例,字符串绑定时会自动对单引号做转义,例如传入a'b,最终存储和比较的值就是a'b,不会因为这个单引号改变SQL结构。这就是预编译带来的天然防护。
还有一点值得注意,#{}会保留参数类型信息。比如传整型参数时,MyBatis会调用setInt,传字符串时调用setString。类型明确后,数据库不会对参数内容再做隐式类型转换,也减少了因类型转换导致的绕过风险。而${}拼接后,所有内容都是字符串,可能出现意料之外的隐式转换。
like查询也是一个典型场景。如果不注意,可能会写出LIKE '%${keyword}%',用户输入%' or '1'='1就能再次注入。改成LIKE CONCAT('%', #{keyword}, '%')后,百分号只作为普通字符拼在SQL里,用户输入完全走参数绑定,风险就消除了。
三、不能无脑替换的场景与白名单方案
#{}虽然安全,但并不是所有位置都能用。像表名、字段名、排序方向这些SQL结构部分,使用#{}会出问题。例如ORDER BY #{column}会被数据库理解成ORDER BY 'username',列名被当成字符串常量,排序效果完全不对。所以动态排序字段有时不得不使用${}。
但这不代表可以放心使用${}。只要这些结构部分来自用户输入,就必须做白名单校验。例如只允许按照id、username、create_time排序,代码可以这样处理:
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "username", "create_time");
public List<User> selectByOrder(String orderBy) {
if (orderBy == null || !ALLOWED_COLUMNS.contains(orderBy)) {
throw new IllegalArgumentException("非法排序字段");
}
return mapper.selectByOrder(orderBy);
}
对应的XML仍然可以使用${},但此时传入的值已经经过严格校验,只可能是白名单里的三个列名之一。
<select id="selectByOrder" parameterType="string" resultType="User">
SELECT * FROM sys_user
ORDER BY ${orderBy} DESC
</select>
动态表名的情况类似。如果业务确实需要根据租户编码选择不同的物理表,不要直接拼接用户传入的字符串,可以维护一个租户编码到表名的映射关系,根据编码查到合法表名后再拼接。这样即便攻击者提交恶意表名,也无法通过映射找到对应表,SQL注入自然被阻断。
四、从漏洞整改到回归验证的完整流程
整改时第一步是全局搜索mapper文件里的${}。在IDEA、VS Code或Notepad++里搜索${即可命中所有位置。逐个判断它属于值绑定还是结构绑定。值绑定一律改成#{},例如where条件里的用户名、密码、id、状态等。结构绑定则按照前面说的白名单方案处理。
修改完成后,不要直接上线,要用典型注入载荷做回归测试。例如登录接口可以测试admin' --、admin' or '1'='1、admin' or 1=1 --等输入。观察返回结果和SQL日志,确认这些输入没有改变查询条件。排序接口则要测试非法列名,例如传入username; DROP TABLE sys_user; --,看服务端是否抛出IllegalArgumentException,并且数据库表没有被删除。
如果项目使用logback或log4j输出MyBatis日志,可以临时开启stdout日志。比如在C:\dev\mybatis\mybatis-config.xml里设置logImpl=STDOUT_LOGGING,观察控制台输出的Preparing语句。修复后应该看到问号占位符,而不是已经拼好的恶意SQL。这个路径示例使用反斜杠,不同项目环境按实际部署位置调整即可。
最后,对于历史遗留项目,不推荐一次性修改所有${},因为有些结构拼接依赖运行时才能确定,可能牵涉大量回归。建议按模块推进,优先修复用户直接可控的查询条件、登录、检索、导出接口。每修完一个模块,就补充对应的注入用例到自动化测试里。长期看,还应该在代码评审中把mapper文件里的${}作为重点检查项,尤其是出现在where、order by、group by后面的情况。