本文将从最经典的 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'可以成功转换为数字1,于是1 = '1'变成1 = 1,结果为true。- 因此
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'"(带单引号的字符串)。
但这种写法有两个致命问题:
-
引号极易写错,多写少写都会直接报错;
-
存在 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 (?, ?)
两个 ? 分别绑定 zxd 和 hhh,每个值都是预编译参数,绝对安全。
关于 collection 属性:
- 传入
List或一般Collection→collection="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 #{} 的真实机制:参数占位符 + 类型安全赋值
#{}在 SQL 编译时被替换为?(占位符)。- 随后通过
PreparedStatement将参数值传递给数据库。 - 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} 的场景中,参数 column 是 String 类型,JDBC 当然会把它当作字符串处理并加上单引号,这就导致了 ORDER BY 'username'。
这正说明:预编译参数绝对不能用于 SQL 关键字、表名、字段名等结构性元素 ,它们必须由开发者在安全校验后通过 ${} 拼入(或使用动态 SQL 框架安全功能)。
六、总结:安全开发核心原则
- 任何来自用户的数值、查询条件、模糊搜索关键字 :一律使用
#{}传递,享受预编译带来的防注入保护。 - 动态表名、字段名、排序字段、分组字段 等需要动态拼接的结构性元素:无法使用
#{},必须先在 Java 层做严格白名单校验 ,才能用${}拼接。 - 模糊查询 :
CONCAT('%', #{keyword}, '%') - IN 查询 : 遍历集合,生成多个
#{}占位符 - 动态排序 :白名单 +
${} - 永远不要图方便用
${}直接拼接用户输入,除非你能100%确保该数据绝对安全(几乎不存在这种场景)。
理解这些底层原理后,无论是 MyBatis、Hibernate 还是纯 JDBC,你都能一眼看出 SQL 注入的风险点,并写出优雅且安全的数据访问代码。