解析 MyBatis 中 #{} 与 ${}区别及 SQL 注入防范(附 Like/In/Order by 安全写法)

本文将从最经典的 SQL 注入案例出发,深入剖析预编译机制、隐式类型转换、MyBatis 中 #{}${} 的本质差异,并详细讲解模糊查询、IN 多值查询、动态排序三大场景的安全写法,最后纠正关于 #{} 自动加引号的常见误区。


一、从一次 SQL 注入攻击说起

假设有一句简单的查询:

复制代码
SELECT first_name, last_name FROM users WHERE user_id = '1'

正常只会返回 user_id = '1' 的那一条用户数据。

但如果后端代码是直接拼接用户输入

复制代码
String user_id = request.getParameter("user_id");  // 用户输入 1' OR 1='1
String sql = "SELECT * FROM users WHERE user_id = '" + user_id + "'";

拼接后的 SQL 就会变成:

复制代码
SELECT first_name, last_name FROM users WHERE user_id = '1' OR 1='1';

为什么这条语句会把整张表的数据全部泄露?请往下看。

二、攻击语句到底为什么永远成立?

2.1 OR 1='1' 为什么是真?

1='1' 看起来是"数字和字符串比较",直觉上会认为不相等,但数据库会进行隐式类型转换

  1. 遇到 数字 = 字符串 时,数据库会把字符串转换成数字再比较。
  2. '1' 可以成功转换为数字 1,于是 1 = '1' 变成 1 = 1,结果为 true
  3. 因此 OR 1='1' 永远为真,WHERE 条件对整个表都成立,所有数据被返回。

2.2 为什么写成 1='1' 而不是 1=1

这是一个闭合引号的技巧

攻击者输入 1' OR 1='1,拼接后 SQL 是:

复制代码
... WHERE user_id = '1' OR 1='1';

如果写成 1' OR 1=1,拼接后会多出一个单独的单引号:

复制代码
... WHERE user_id = '1' OR 1=1';

SQL 语法错误,攻击失败。因此 1='1' 的形式可以恰好把原有的 ' 闭合掉,让 SQL 正常运行。

2.3 user_id = '1' 为什么也能正常查数据?

许多初学者会以为 user_id 如果是数字字段,写 user_id = '1' 应该失败,其实不会:

数据库发现两边类型不匹配时,会自动把字符串 '1' 转为数字 1,最终相当于 user_id = 1,查询正常进行。
隐式类型转换让不规范写法得以执行,但这也被注入攻击利用了。

三、MyBatis 中 #{}${} 的本质区别

在讨论后续场景之前,必须先搞明白这两个占位符的底层机制:

写法 本质 SQL 生成方式 是否防注入 使用场景
#{变量} 预编译参数占位符 生成 ?,由 JDBC 按类型安全赋值 ✅ 安全 传递(如条件值、插入数据)
${变量} 直接字符串拼接 直接将变量内容替换到 SQL 中 ❌ 不安全 只能用于不接收用户输入的动态表名/字段名,且必须做好白名单校验

简单理解:

  • #{} 会把用户输入当成数据,无论输入多么恶意的字符串,都只会被当作一个普通参数处理。
  • ${} 会把用户输入当成 SQL 语句的一部分,输入什么就直接拼什么,完全丧失防御能力。

防注入的终极原则:所有用户输入的值都必须用 #{} 传递给 SQL。

3.1 一个直观的对比:${} 拼接字符串为何直接报错?

为帮助你彻底看清二者的差异,我们来看一个最简单的查询例子。

Mapper XML 写法(错误):

复制代码
select * from user where name = ${value}

Java 测试代码中传入:String name = "zs";

${} 拼接后生成了什么?

${} 是直接字符串拼接,不会自动加单引号 ,MyBatis 会把参数 zs 原封不动拼进 SQL,最终执行的语句是:

复制代码
select * from user where name = zs;
数据库为什么会报错?

MySQL 在解析 SQL 时有一条关键规则:

  • 带单引号的内容 :当作「字符串值」处理,例如 'zs',表示"值为 zs 的字符串"。
  • 不带单引号的内容 :当作「标识符(列名/表名)」处理,例如 zs,表示"名为 zs 的字段"。

因此数据库在看到 name = zs 时,会去表里寻找有没有一个叫 zs 的列,找不到就直接抛出错误:
Unknown column 'zs' in 'where clause'

换成 #{} 为什么就正常?

如果 SQL 改为:

复制代码
select * from user where name = #{value}

#{} 是预编译参数,它会生成占位符 ?,并由 JDBC 按类型安全赋值。因为 value 是字符串类型,JDBC 会自动给它加上单引号,最终执行的等效 SQL 为:

复制代码
select * from user where name = 'zs';

数据库将 'zs' 视为一个字符串值去匹配,可以正常查出数据,而且完全防注入。

如果非要用 ${} 怎么办?(极其不推荐)

勉强可行的办法是手动加引号,例如:

  • 在 Mapper 中写成 where name = '${value}'
  • 或者在 Java 代码中传入 "'zs'"(带单引号的字符串)。

但这种写法有两个致命问题:

  1. 引号极易写错,多写少写都会直接报错;

  2. 存在 SQL 注入风险 ,例如用户传入恶意参数 zs' or 1=1,拼接后 SQL 变为:

    复制代码
    select * from user where name = 'zs' or 1=1;

    OR 1=1 永真,直接泄露全表数据。这就是"使用 ${} 拼接值"成为 SQL 注入根本原因之一的典型例子。

注意:动态排序时字段名不能加引号,因此有时不得不用 ${column},但必须配合 Java 层白名单 ,而普通条件查询根本不需要 ${},用 #{} 即可安全搞定。

四、三大常见场景的安全写法

4.1 模糊查询 Like

需求 :查询用户名包含某个关键字的用户,实现 WHERE username LIKE '%关键字%'

❌ 错误写法 1:#{} 直接放在引号里
复制代码
SELECT * FROM user_table WHERE username LIKE '%#{username}%'
  • 结果:直接报错或查不到数据。
  • 原因#{} 被解析成占位符 ?,最终 SQL 变成 LIKE '%?%',这里的 ? 是字符串的一部分,无法绑定参数。
❌ 错误写法 2:用 ${} 拼接
复制代码
SELECT * FROM user_table WHERE username LIKE '%${username}%'
  • 结果:能正常查询,但存在 SQL 注入漏洞。

  • 原因 :用户输入 zxd' OR 1=1# 会直接拼入,变为:

    复制代码
    WHERE username LIKE '%zxd' OR 1=1#%'

    单引号被闭合,OR 1=1 变成永远为真的条件,# 注释掉剩余部分,全表数据泄露。

✅ 安全写法:使用 CONCAT 函数
复制代码
SELECT * FROM user_table WHERE username LIKE CONCAT('%', #{username}, '%')
  • #{username} 作为独立参数,生成 ?,最终 SQL:

    复制代码
    WHERE username LIKE CONCAT('%', ?, '%')
  • 即使用户输入恶意字符串,也只会被当作纯文本去匹配,不会破坏 SQL 结构。

4.2 IN 多值查询

需求 :查询用户名在某个列表中的用户,如 WHERE username IN ('zxd', 'hhh')

❌ 错误写法 1:直接往 #{} 中传逗号分隔字符串
复制代码
SELECT * FROM user_table WHERE username IN (#{usernames})

Java 传入字符串 "zxd,hhh",最终 SQL 为:

复制代码
WHERE username IN ('zxd,hhh')

整个字符串被当作一个用户名 去匹配,查不到任何数据(除非真有用户名就叫 zxd,hhh)。

❌ 错误写法 2:用 ${} 拼接字符串
复制代码
SELECT * FROM user_table WHERE username IN (${usernames})

Java 传入 "'zxd','hhh'",SQL 拼接正确,能查到数据。

但用户输入恶意参数 'hhh') OR 1=1#,SQL 变为:

复制代码
WHERE username IN ('hhh') OR 1=1#)

同样导致全表泄露。

✅ 安全写法:使用 标签

传参时必须使用 Java 集合/数组 ,然后在 XML 中遍历生成多个 #{} 占位符:

复制代码
List<String> nameList = Arrays.asList("zxd", "hhh");
userMapper.getUserFromList(nameList);

<select id="getUserFromList" resultType="user">
    SELECT * FROM user_table WHERE username IN
    <foreach collection="list" item="name" open="(" separator="," close=")">
        #{name}
    </foreach>
</select>

生成 SQL:

复制代码
WHERE username IN (?, ?)

两个 ? 分别绑定 zxdhhh,每个值都是预编译参数,绝对安全。

关于 collection 属性

  • 传入 List 或一般 Collectioncollection="list"
  • 传入数组 → collection="array"
  • 传入 Map 中的集合 → collection="map的key"

foreach 不会自动识别你传的是不是集合 ,必须你自己在 Java 层传集合,并在 XML 中明确指定 collection

4.3 动态排序 ORDER BY

需求 :允许用户指定按某个字段排序,如 ORDER BY username

❌ 错误写法 1:ORDER BY #{column}
复制代码
SELECT * FROM user_table ORDER BY #{column} LIMIT 0,1
  • 现象:排序不生效,返回的还是默认顺序。
  • 原因#{column} 会将参数作为字符串值 处理,生成 ORDER BY 'username'
    在 MySQL 中,ORDER BY '常量' 等同于对所有行施加一个相同的常量,排序相当于无效。
❌ 错误写法 2:ORDER BY ${column}
复制代码
SELECT * FROM user_table ORDER BY ${column} LIMIT 0,1

正常传入 username 没问题。

但攻击者传入 username#,SQL 变为:

复制代码
ORDER BY username# LIMIT 0,1

# 是注释符,LIMIT 0,1 被注释掉,返回全部数据。更危险的是可能构造出删表等恶意操作。

✅ 安全方案:Java 层白名单 + ${}

因为字段名不能加引号,此处无法使用 #{},只能在 Java 层严格限制输入值,确保拼入的字段名是安全的。${} 不是不能用,而是必须配合白名单用!

方案 1:白名单校验

复制代码
String sort = request.getParameter("sort");
String[] whitelist = {"id", "username", "password"};
if (!Arrays.asList(whitelist).contains(sort)) {
    sort = "id";  // 不在白名单内一律使用默认安全字段
}

然后将校验后的 sort 传入 XML 的 ${column}

用户输入 username# 不在白名单中,会被替换为 id,注入失效。

方案 2:索引映射

复制代码
String sortIndex = request.getParameter("sort");
switch (sortIndex) {
    case "1": sort = "id"; break;
    case "2": sort = "username"; break;
    case "3": sort = "password"; break;
    default: sort = "id";
}

用户只能传 1/2/3,完全隔绝恶意字段名。

五、深入理解 #{}:它真的会"自动加单引号"吗?

很多人初学会产生一个误解:"#{} 会无脑给所有参数加单引号"

其实这个说法并不准确,我们来看看背后的真相。

5.1 #{} 的真实机制:参数占位符 + 类型安全赋值

  1. #{} 在 SQL 编译时被替换为 ?(占位符)。
  2. 随后通过 PreparedStatement 将参数值传递给数据库。
  3. JDBC 驱动根据 参数的 Java 类型 决定是否需要加引号:
    • 字符串(String)、日期(Date)等 → 会被加上单引号
    • 数字(int、long、double 等) → 不加引号
    • null → 直接设为 null

示例:

复制代码
<!-- id 为 Integer,name 为 String -->
SELECT * FROM user WHERE id = #{id} AND name = #{name}

预编译 SQL:

复制代码
SELECT * FROM user WHERE id = ? AND name = ?

真正执行时:

复制代码
SELECT * FROM user WHERE id = 123 AND name = 'Alice'

数字 123 无引号,字符串 'Alice' 有引号。所以 #{} 并不是无脑加单引号,而是由 JDBC 按类型正确处理

5.2 为什么常量字段名会被加引号?

ORDER BY #{column} 的场景中,参数 columnString 类型,JDBC 当然会把它当作字符串处理并加上单引号,这就导致了 ORDER BY 'username'

这正说明:预编译参数绝对不能用于 SQL 关键字、表名、字段名等结构性元素 ,它们必须由开发者在安全校验后通过 ${} 拼入(或使用动态 SQL 框架安全功能)。

六、总结:安全开发核心原则

  1. 任何来自用户的数值、查询条件、模糊搜索关键字 :一律使用 #{} 传递,享受预编译带来的防注入保护。
  2. 动态表名、字段名、排序字段、分组字段 等需要动态拼接的结构性元素:无法使用 #{},必须先在 Java 层做严格白名单校验 ,才能用 ${} 拼接。
  3. 模糊查询CONCAT('%', #{keyword}, '%')
  4. IN 查询 : 遍历集合,生成多个 #{} 占位符
  5. 动态排序 :白名单 + ${}
  6. 永远不要图方便用 ${} 直接拼接用户输入,除非你能100%确保该数据绝对安全(几乎不存在这种场景)。

理解这些底层原理后,无论是 MyBatis、Hibernate 还是纯 JDBC,你都能一眼看出 SQL 注入的风险点,并写出优雅且安全的数据访问代码。

相关推荐
S1998_1997111609•X1 小时前
Phash的系统通信工程及恶意注入污染蜜罐轮替探测阻断正常通讯协议系统的dog 通用原理及行为阻击至联合国管理清理全栈
安全·百度·哈希算法·量子计算·开闭原则
会编程的土豆1 小时前
Gin 框架第一课:从 0 搞懂 Gin 最基础的路由
数据库·sql·gin·goland
xixixi777771 小时前
《从心理诱导突破Claude到AI仿冒直播首张拘留单:AI安全、监管与商用的三重转折点》
大数据·网络·人工智能·安全·ai·大模型·风险
立控信息LKONE1 小时前
门禁机、控制器等库室安防设施、实现库室智能联动,一体报警
大数据·人工智能·安全
@insist1231 小时前
信息安全工程师-内生安全核心技术:白名单与可信计算深度解析
安全·软考·信息安全工程师·软件水平考试
2301_780789661 小时前
2025年服务器漏洞生存指南:从应急响应到长效免疫的实战框架
网络·安全·web安全·架构·ddos
深度智能Ai1 小时前
卡巴斯基:MD5 加密已完全不安全
安全·md5·卡巴斯基
雅俗数据库1 小时前
OCP实验 | 03-SQL优化
数据库·sql
承渊政道2 小时前
Oracle迁移避坑:一个(+)写错,LEFT JOIN可能变INNER JOIN
运维·服务器·数据库·数据仓库·学习·安全·oracle