MyBatis中${}和#{}的区别,一句话说清楚:${}是字符串直接拼接,#{}是预编译参数绑定。用错了,SQL注入漏洞就来了。实际开发中,大量SQL注入事故都不是因为开发者不懂原理,而是因为图省事、抄代码、改需求时随手把#{}改成了${}。今天这篇文章,我会把所有常见的误用场景、真实案例、审查方法和修复方案一次性讲透,帮你在代码审查阶段就把这个高危漏洞堵死。
一、${}和#{}的本质区别,必须刻在脑子里
先把底层机制搞明白。${}在MyBatis中属于字符串替换,它会在SQL语句编译之前,直接把变量值拼接到SQL字符串里。比如你写了SELECT * FROM user WHERE name = '${name}',当name的值是"admin' OR '1'='1"时,最终执行的SQL就变成了SELECT * FROM user WHERE name = 'admin' OR '1'='1',这就是经典的SQL注入。
而#{}使用的是PreparedStatement预编译机制。MyBatis会把#{}的位置替换成问号占位符?,然后通过JDBC的参数绑定把值安全地传进去。数据库在编译阶段就已经确定了SQL结构,用户输入的内容只会被当作纯数据处理,永远不会被解析成SQL语法。所以#{}在绝大多数场景下是安全的。
但这里有个关键细节很多人忽略了:${}并非完全不能用。在ORDER BY、动态表名、动态列名这些场景下,因为数据库不支持预编译占位符来指定结构部分,你不得不用${}。问题在于,很多人把这种"不得不用"的场景,泛化成了"随便用"。
二、最常见的五大误用案例,逐条拆解
案例一:WHERE条件中直接使用${}
这是最高频的错误。开发人员为了快速实现模糊查询或者动态条件,直接写成这样:
SELECT * FROM orders WHERE status = '${status}' AND create_time > '${startTime}'
攻击者只要传入status参数为"closed' OR '1'='1' --",整张表的数据就全泄露了。正确写法应该是:
SELECT * FROM orders WHERE status = #{status} AND create_time > #{startTime}
如果确实需要模糊查询LIKE,也要用#{}配合concat函数,而不是${}拼接百分号。
SELECT * FROM orders WHERE product_name LIKE CONCAT('%', #{keyword}, '%')
案例二:IN查询中使用${}拼接
很多人觉得IN后面的参数列表没法用#{},于是自己拼字符串:
SELECT * FROM users WHERE id IN (${ids})
当ids传入"1,2,3) OR (1=1"时,注入就发生了。正确做法是用MyBatis的foreach标签:
<select id="selectByIds" resultType="User">
SELECT * FROM users WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
这样每个id都会被预编译绑定,完全安全。
案例三:动态表名和列名使用${}但未做校验
这是最容易被忽视的灰色地带。比如多租户系统需要动态切换表名:
SELECT * FROM ${tableName} WHERE id = #{id}
表名本身不能用#{},因为预编译不支持表名作为参数。但如果tableName是从前端直接传过来的,没有任何白名单校验,攻击者可以传入"users; DROP TABLE orders; --"之类的恶意内容。正确做法是在Service层做严格的白名单校验:
public String validateTableName(String tableName) {
Set<String> allowedTables = Set.of("orders_2024", "orders_2023", "users");
if (!allowedTables.contains(tableName)) {
throw new IllegalArgumentException("非法表名");
}
return tableName;
}
只有通过白名单的表名才能进入SQL拼接,这样${}的风险就被控制住了。
案例四:ORDER BY和GROUP BY中滥用${}
排序字段同样不能用#{},因为ORDER BY后面需要的是列名而不是值。常见写法:
SELECT * FROM products ORDER BY ${orderBy} ${orderDir}
如果orderBy参数是"price DESC; DELETE FROM products; --",后果不堪设想。修复方案同样是白名单校验:
public String validateOrderBy(String orderBy) {
Set<String> allowedColumns = Set.of("price", "create_time", "sales_count");
if (!allowedColumns.contains(orderBy)) {
throw new IllegalArgumentException("非法排序列");
}
return orderBy;
}
案例五:MyBatis动态SQL中if标签里的${}误用
有些开发者在动态SQL里这样写:
<if test="column != null">
AND ${column} = #{value}
</if>
column参数如果来自前端,直接拼接到SQL里就是注入点。这种场景必须在后端做枚举或白名单映射,绝对不能信任前端传入的列名。
三、代码审查时的具体检查清单
做安全审查时,我建议按以下步骤逐一排查:
第一步,全局搜索所有Mapper XML文件中的${}符号。用IDE的全局搜索功能,或者用grep命令批量扫描。每一个${}都要单独评估,问自己三个问题:这个位置能不能用#{}替代?如果不能,有没有做白名单校验?校验逻辑是否在Service层而不是前端?
第二步,检查所有动态SQL拼接场景。重点关注if、choose、where、set标签中出现的${}。特别是set标签用于UPDATE语句时,列名如果用${}拼接而没有校验,风险极高。
第三步,审查Mapper接口层的参数传递。看看有没有把整个对象或者Map直接传进去做${}拼接的情况。这种写法不仅有注入风险,还会让代码可读性极差。
第四步,检查是否有自定义的SQL拼接工具类。有些团队封装了通用的SQL构建器,如果内部使用了字符串拼接而不是参数化查询,那就是系统性风险。
第五步,确认项目中是否开启了MyBatis的日志打印。在开发和测试环境中开启完整SQL日志,可以帮助发现那些隐藏的${}拼接问题。生产环境建议只打印参数不打印完整SQL。
四、从架构层面彻底规避风险的建议
光靠审查是不够的,还要从制度和架构上建立防线。
首先,制定团队编码规范,明确规定:所有用户输入参与SQL构建的场景,必须使用#{};只有在结构部分(表名、列名、排序)且经过白名单校验后才允许使用${}。把这条写进Code Review的检查项里。
其次,引入静态代码分析工具。像SonarQube、FindBugs、Alibaba Java Coding Guidelines插件都能检测到${}的使用,可以在CI/CD流水线中自动拦截。虽然会有误报,但至少能把明显的问题暴露出来。
再次,对于复杂的动态查询场景,考虑使用MyBatis-Plus的QueryWrapper或者JPA的Criteria API。这些框架在底层帮你做了参数化处理,大幅降低了手写SQL出错的概率。
最后,定期做渗透测试。不要觉得内部审查够了就万事大吉,让安全团队或者第三方做一次专项渗透,用实际攻击来验证你的防御是否有效。很多漏洞都是在"觉得没问题"的时候被发现的。
五、总结:记住一个核心原则
${}不是洪水猛兽,但它是一把没有保险的刀。能用#{}的地方绝不用${},必须用${}的地方一定要加白名单。SQL注入是OWASP Top 10里年年上榜的老问题,但在MyBatis项目中依然高发,根本原因就是开发者对这两个符号的区别没有形成肌肉记忆。把今天讲的这五个案例和审查清单用起来,你的项目安全等级至少能提升一个档次。
