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>

注意看:keywordminPricemaxPrice 这些"值"用 #{}sortFieldsortDirection 这种"结构"用 ${}。但 ${} 走到这里的时候,已经在 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://jiangyi.space/articles/2026/06/02/1780131010690.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消