SQL 注入深度防御:MyBatis 动态 SQL 也能注入?预编译+白名单过滤双保险!
前两天群里有人发了个截图,他们公司一个后台管理系统被人拖了库。查了半天,问题出在一段 MyBatis 的动态排序代码上。那哥们特别委屈:"我没拼字符串啊,我用的是 MyBatis,框架不是自带防注入吗?"
我一看代码,
${orderBy}赫然在目。他以为 MyBatis 是银弹,实际上$和#差了一个安全分水岭。
今天聊的这个话题,但凡写过 Java Web 项目的人都会遇到。但很多人对 SQL 注入的认知停留在"用了 MyBatis 就安全了",这个想法比注入本身还危险。
一句话搞懂:# 和 $ 到底差在哪
先别急着看方案,把这个最基础的概念弄明白。
MyBatis 里写 SQL,有两种方式塞参数:
-- 方式一:#{}
SELECT * FROM user WHERE name = #{name}
-- 方式二:${}
SELECT * FROM user WHERE name = '${name}'
区别在哪?#{} 走预编译,${} 是字符串拼接。
用 #{} 的时候,MyBatis 会把 SQL 发给数据库之前,先把参数位置用 ? 占位,参数值单独传给数据库。数据库一看,哦,这个 ? 就是一个值,不是什么 SQL 关键字,不会执行它。
预编译的 SQL:SELECT * FROM user WHERE name = ?
参数:'admin' OR '1'='1'
数据库理解:我要找 name = "admin' OR '1'='1" 的人
结果:没有这个人,安全 ✅
用 ${} 的时候就不一样了。MyBatis 直接把你的参数拼进 SQL 字符串里,拼完再发给数据库。数据库收到的是一整条完整的 SQL,它可分不清哪个是你拼进去的值、哪个是原本的 SQL 语法。
拼接后的 SQL:SELECT * FROM user WHERE name = 'admin' OR '1'='1'
数据库理解:我要找 name = 'admin' 或者 1=1 的人
结果:全表都出来了 💀
所以结论很简单:能用 #{} 的地方绝对不要用 ${}。
但问题来了 —— 有些场景,${} 根本躲不开。
这几个场景,逼着你用 ${}
MyBatis 的 #{} 虽然安全,但它有个限制:只能在值的位置用。 SQL 里的表名、字段名、排序方向、IN 列表,这些不是"值",是 SQL 语法的一部分,#{} 处理不了。
比如下面这几个需求,你告诉我 #{} 怎么写?
场景一:动态排序
SELECT * FROM product ORDER BY #{sortField} #{sortDirection}
错了,这不会报错,但排出来的结果是乱的。因为 #{sortField} 会被当成字符串值处理,实际 SQL 变成:
SELECT * FROM product ORDER BY 'price' 'DESC'
字段名被加了引号,排序不生效。所以只能写:
SELECT * FROM product ORDER BY ${sortField} ${sortDirection}
场景二:动态表名
SELECT * FROM ${tableName} WHERE id = #{id}
分表场景下,表名本身就是动态的,#{} 处理不了表名。
场景三:IN 查询
老版本 MyBatis 的 #{} 对 IN 支持不好,WHERE id IN (#{ids}) 会变成 WHERE id IN ('1,2,3'),只匹配一条。以前很多人被迫用 ${} 拼。
不过新版本 MyBatis(3.4.5+)已经支持
<foreach>标签正确处理 IN 了,这个场景现在可以不用${}。
场景四:LIKE 模糊查询
SELECT * FROM user WHERE name LIKE '%${keyword}%'
#{keyword} 写在这个位置,百分号外面的引号会冲突。不过这个现在也有办法绕:用 CONCAT('%', #{keyword}, '%') 就行,不一定非要 ${}。
既然躲不开,那就用白名单兜底
这些场景的共同特点是:参数来自前端,但必须拼进 SQL 结构里。
攻击者如果在这个参数里塞一段 SQL,比如 sortField = "price; DROP TABLE product--",${} 会原样拼进去,结果你懂的。
怎么防?思路上很简单:凡是拼进 SQL 结构的参数,一律做白名单校验。不在名单里的,直接拒绝,不给它靠近 SQL 的机会。
白名单校验的伪代码
ALLOWED_COLUMNS = ["id", "name", "price", "create_time"]
ALLOWED_DIRECTIONS = ["ASC", "DESC"]
function validateOrderBy(sortField, sortDirection):
if sortField not in ALLOWED_COLUMNS:
throw "非法的排序字段"
if sortDirection.upper() not in ALLOWED_DIRECTIONS:
throw "非法的排序方向"
逻辑就是这么简单:不是让你去识别'这个参数里有没有恶意 SQL',而是反过来,只认你提前列好的那几个值。 白名单思维和黑名单思维最大的区别就在这里 —— 黑名单是你去猜攻击者会写什么,永远猜不完;白名单是你只开门给你认识的人,陌生人一概不放。
生产级的白名单校验器
把上面这个思路工程化,大概长这样:
class SqlWhitelistValidator:
// 排序字段白名单 —— 建议放在枚举里
SORTABLE_COLUMNS = {
"product": ["id", "name", "price", "sales", "create_time"],
"order": ["id", "order_no", "amount", "status", "create_time"]
}
// 排序方向白名单
DIRECTIONS = ["ASC", "DESC"]
validateSort(bizType, sortField, sortDirection):
columns = SORTABLE_COLUMNS[bizType]
if columns == null or sortField not in columns:
throw "排序字段不在白名单内: " + sortField
if sortDirection not in DIRECTIONS:
throw "排序方向不在白名单内: " + sortDirection
注意白名单是按业务类型分组的。product 表能排序的字段和 order 表能排序的字段不一样,不能混用。攻击者知道 product 表有 price 字段,但如果他拿着 product 的接口去查 order 表的数据,至少字段名对不上,绕不过去。
双保险:预编译 + 白名单,各守各的防线
把这两层结合起来,就是一套完整的 SQL 注入防御体系。
请求进入
│
├─ 第一层:预编译(#{})
│ 适用范围:所有"值"类型的参数
│ 作用:参数值永远不会被当成 SQL 执行
│
├─ 第二层:白名单校验
│ 适用范围:必须用 ${} 的动态 SQL 结构参数
│ 作用:可拼接的值被严格限定在预定义的集合内
│
└─ 两层都通过 → 执行 SQL
两层分工明确:预编译管"值",白名单管"结构"。 不越界,也不留死角。
在代码里落地的姿势大概是这样的:
// Service 层
function queryProducts(keyword, sortField, sortDirection, minPrice, maxPrice):
// 第二层:白名单校验动态 SQL 结构参数
validator.validateSort("product", sortField, sortDirection)
// 第一层:预编译保护值类型参数(在 Mapper XML 里用 #{})
return mapper.query(keyword, sortField, sortDirection, minPrice, maxPrice)
<!-- Mapper XML -->
<select id="query" resultType="Product">
SELECT * FROM product
WHERE name LIKE CONCAT('%', #{keyword}, '%')
AND price BETWEEN #{minPrice} AND #{maxPrice}
ORDER BY ${sortField} ${sortDirection}
</select>
注意看:keyword、minPrice、maxPrice 这些"值"用 #{},sortField 和 sortDirection 这种"结构"用 ${}。但 ${} 走到这里的时候,已经在 Service 层被白名单筛过一遍了,能活到 XML 的 ${} 里的值,一定是安全的。
几个容易漏掉的点
MyBatis-Plus 也保不了你
很多人项目里用的是 MyBatis-Plus,觉得框架封装得那么好,肯定没问题。但 MyBatis-Plus 的 orderBy 方法底层也是拼接,你传什么它拼什么:
// 这样写,orderBy 参数来自前端,跟裸 ${} 一样危险
wrapper.orderBy(true, true, orderByField)
MyBatis-Plus 的 apply() 方法也是一样的坑,它是直接拼接,不做任何校验。原则是一样的:凡是来自前端的参数参与拼接,不管中间经过了几层封装,都得做白名单。
${} 拼接 Like 的经典错误
-- ❌ 错误:会因为引号问题导致 SQL 语法错误
WHERE name LIKE '%#{keyword}%'
-- ❌ 错误:注入漏洞
WHERE name LIKE '%${keyword}%'
-- ✅ 正确:用 CONCAT 配合 #{}
WHERE name LIKE CONCAT('%', #{keyword}, '%')
日志脱敏别忘了
排查问题的时候,经常会把完整 SQL 打到日志里。如果 SQL 里拼了用户输入,日志就成了第二个泄露点。至少把敏感字段(手机号、身份证、密码)脱敏后再打印。
白名单不要硬编码到业务代码里
时间长了你会发现白名单散落在各个 Service 里,改一个字段名要改好几处。建议把白名单配置抽到配置文件或者枚举类里统一管理:
# application.yml
sql-whitelist:
product:
sortable-columns: id, name, price, sales, create_time
order:
sortable-columns: id, order_no, amount, status, create_time
启动时加载到内存,配合一个 @ValidatedSort 自定义注解,用起来就清爽多了。
总结
SQL 注入不是什么新问题,但架不住它年年在 OWASP Top 10 里排第一。
核心就两件事:
1. 能用 #{} 的地方,死都不要用 ${}。 预编译是数据库层面最硬核的防护,参数永远不可能逃逸出去当 SQL 执行。
2. 必须用 ${} 的地方,老老实实上白名单。 别想着自己去判断参数"有没有恶意",你不认识的东西比你认识的多得多。只放行你知道是安全的,其他全挡掉。
两层加在一起,既不让"值"变成 SQL,也不让"结构"里混进不认识的东西。基本上,能把你能想到的 SQL 注入姿势都封死。
最后说一句,安全这种事,永远不要信任用户的输入。 你觉得前端做了校验、你觉得参数是下拉框选的、你觉得这个接口只有内部人员用——这些都是你觉得。防范措施要按最坏的情况来,因为你永远不知道攻击者会从哪个口子进来。
觉得有用的话,转发给你们组里还在裸写 ${} 的同事。
标题:SQL 注入深度防御:MyBatis 动态 SQL 也能注入?预编译+白名单过滤双保险!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/02/1780131010690.html
公众号:服务端技术精选
评论