MyBatis的$和#区别:你以为防注入就够了?
一、一秒钟制造线上事故:这行代码值一个P0
less
java
1// 某个深夜,实习生小王写了这行代码:
2@Select("SELECT * FROM user WHERE name = '${name}'")
3User findByName(@Param("name") String name);
4
测试环境风平浪静,上线第一天,数据库被拖库了。
sql
1// 攻击者传参:
2name = "'; DROP TABLE user; --"
3
4// 最终执行的SQL:
5SELECT * FROM user WHERE name = ''; DROP TABLE user; --'
6
一个$符号,差一点毁掉整个数据库。 这不是段子,是每年都在真实发生的生产事故。
二、#和$:一字之差,天壤之别
先看最直观的对比:
| 特性 | #{} |
${} |
|---|---|---|
| 本质 | 预编译占位符 | 字符串拼接 |
| SQL表现 | WHERE name = ? |
WHERE name = 'Tom' |
| 防注入 | ✅ 自动转义 | ❌ 裸拼接,形同虚设 |
| 性能 | 稍慢(预编译开销) | 稍快(直接拼) |
| 适用场景 | 值(where/insert/update) | 表名/列名/order by/动态表 |
一句话总结:#是安全的,是危险的,但在某些场景下你不得不用。
三、深入源码:#到底做了什么?
当你写#{name}时,MyBatis在底层到底干了什么?
第一步:解析为ParameterMapping
arduino
java
1// org.apache.ibatis.mapping.ParameterMapping
2public class ParameterMapping {
3 private final String property;
4 private final TypeHandler<?> typeHandler;
5 private final JdbcType jdbcType;
6 // ...
7}
8
MyBatis把#{name}解析成一个ParameterMapping对象,记录了参数名、类型处理器、JDBC类型。
第二步:预编译PreparedStatement
ini
java
1// org.apache.ibatis.executor.statement.PreparedStatementHandler
2public PreparedStatementHandler(...) {
3 // SQL已经变成:SELECT * FROM user WHERE name = ?
4 String sql = "SELECT * FROM user WHERE name = ?";
5 PreparedStatement ps = connection.prepareStatement(sql);
6}
7
注意:此时SQL中已经没有具体值了,只剩一个问号。
第三步:setParameters时安全赋值
arduino
java
1// org.apache.ibatis.type.StringTypeHandler
2public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) {
3 ps.setString(i, parameter); // JDBC驱动底层会自动转义特殊字符
4}
5
关键点来了:JDBC的PreparedStatement在setString时,会对引号、分号、注释符等进行转义处理 。攻击者传入的'; DROP TABLE user; --会被当作一个完整的字符串值,而不是SQL片段。
最终数据库看到的是:
sql
sql
1WHERE name = ''; DROP TABLE user; --'
2-- 整个东西被当成了name字段的值,安全!
3
再看$:裸奔的字符串拼接
kotlin
java
1// 你写的:
2@Select("SELECT * FROM user WHERE name = '${name}'")
3
4// MyBatis做的事情极其简单:
5String sql = "SELECT * FROM user WHERE name = '" + name + "'";
6// 然后直接执行这条拼接好的SQL
7
没有预编译,没有转义,没有任何保护。 你传什么,它就拼什么,数据库照单全收。
用一张图看清区别:
bash
1#{} 流程:
2 SQL模板: "WHERE name = ?" ──→ 预编译 ──→ setString(1, "Tom") ──→ 安全✅
3
4${} 流程:
5 SQL模板: "WHERE name = '" + name + "'" ──→ 直接执行 ──→ 注入💀
6
四、血泪案例:三种经典注入姿势
案例1:万能密码绕过
less
java
1@Select("SELECT * FROM user WHERE username = '${username}' AND password = '${password}'")
2User login(@Param("username") String username, @Param("password") String password);
3
攻击者传参:
ini
1username = "admin' --"
2password = "任意值"
3
最终SQL:
sql
sql
1SELECT * FROM user WHERE username = 'admin' --' AND password = 'xxx'
2-- 注释符后面全被吃掉,密码验证直接消失
3
一行${},整个登录系统形同虚设。
案例2:联合查询拖库
less
java
1@Select("SELECT * FROM user WHERE id = ${id}")
2User findById(@Param("id") int id);
3
攻击者传参:
ini
1id = "1 UNION SELECT username, password, 3, 4 FROM admin_user --"
2
最终SQL:
sql
sql
1SELECT * FROM user WHERE id = 1 UNION SELECT username, password, 3, 4 FROM admin_user --
2
用户表和管理员表的账号密码,一次性全泄露。
案例3:最隐蔽的------order by注入
less
java
1@Select("SELECT * FROM user ORDER BY ${orderBy}")
2List<User> list(@Param("orderBy") String orderBy);
3
很多人觉得:order by又不能union,能有什么风险?
攻击者传参:
ini
1orderBy = "id; UPDATE user SET balance = 0 WHERE 1=1 --"
2
如果数据库允许多语句执行(MySQL默认允许),直接修改全表数据。
即使不允许多语句,还可以用报错注入:
ini
1orderBy = "(CASE WHEN (SELECT version()) LIKE '5%' THEN name ELSE id END)"
2-- 通过报错信息推断数据库版本,进而构造更复杂的攻击
3
任何${},无论用在哪里,都是定时炸弹。
五、你不得不用的场景:不用,功能就实现不了
说完危险,必须说真话------有些场景下,#根本无法替代$ :
场景1:动态表名
less
java
1// 按月分表:user_202401, user_202402, user_202403...
2@Select("SELECT * FROM user_${month} WHERE id = #{id}")
3User findByMonth(@Param("month") String month, @Param("id") Long id);
4
表名不能用?占位符,因为SQL语法规定表名必须是字面量。
场景2:动态列名(排序)
less
java
1@Select("SELECT * FROM user ORDER BY ${orderBy} ${orderDir}")
2List<User> list(@Param("orderBy") String orderBy, @Param("orderDir") String orderDir);
3
ORDER BY后面必须跟列名,不能用?值。
场景3:动态条件(in语句)
kotlin
java
1// 错误写法:会把整个list当成一个参数
2@Select("SELECT * FROM user WHERE id IN (#{ids})")
3// 最终变成:WHERE id IN ('1,2,3') ← 变成了一个字符串,不是三个值
4
5// 正确写法1:用$(但要白名单校验)
6@Select("SELECT * FROM user WHERE id IN (${ids})")
7// ids = "1, 2, 3" → WHERE id IN (1, 2, 3) ✅
8
9// 正确写法2:用foreach(推荐)
10@Select("<script>" +
11 "SELECT * FROM user WHERE id IN " +
12 "<foreach collection='ids' item='id' open='(' separator=',' close=')'>" +
13 "#{id}" +
14 "</foreach>" +
15 "</script>")
16List<User> findByIds(@Param("ids") List<Long> ids);
17// 每个id都走#{}预编译,安全!
18
场景4:LIMIT分页(动态偏移)
less
java
1@Select("SELECT * FROM user LIMIT ${offset}, ${size}")
2List<User> page(@Param("offset") int offset, @Param("size") int size);
3
LIMIT后面的数字不能用占位符,必须字面量。
六、必须用$时的保命指南:五道防线
既然$不得不用,那就把风险降到最低。请严格遵守以下五条:
防线1:白名单校验(最重要!)
typescript
java
1public List<User> findByOrder(String orderBy, String orderDir) {
2 // ✅ 只允许指定的列名
3 Set<String> allowedColumns = Set.of("id", "name", "create_time");
4 if (!allowedColumns.contains(orderBy)) {
5 throw new IllegalArgumentException("非法排序字段");
6 }
7
8 // ✅ 只允许指定的排序方向
9 if (!"ASC".equalsIgnoreCase(orderDir) && !"DESC".equalsIgnoreCase(orderDir)) {
10 throw new IllegalArgumentException("非法排序方向");
11 }
12
13 return userMapper.list(orderBy, orderDir);
14}
15
原则:凡是${}接收的参数,必须经过白名单校验,一个字符都不能信。
防线2:转义函数兜底
MyBatis 3.5+提供了内置转义函数:
bash
xml
1<select id="findByTable">
2 SELECT * FROM ${tableName} WHERE id = #{id}
3</select>
4
调用时:
ini
java
1String safeTable = Configuration.parser(tableName);
2// 内部会对特殊字符进行转义
3
或者自己封装:
typescript
java
1public static String escapeIdentifier(String identifier) {
2 if (identifier == null || !identifier.matches("[a-zA-Z0-9_]+")) {
3 throw new IllegalArgumentException("非法标识符");
4 }
5 return "`" + identifier + "`"; // MySQL用反引号包裹
6}
7
防线3:永远不要用${}接收用户直接输入
less
java
1// ❌ 绝对禁止
2@Select("SELECT * FROM user WHERE name = '${name}'")
3User find(@Param("name") String name); // name来自前端请求
4
5// ✅ 正确做法
6@Select("SELECT * FROM user WHERE name = #{name}")
7User find(@Param("name") String name); // 走预编译,安全
8
用户输入 → #{};系统内部参数(如配置项)→ ${}(且需白名单)
防线4:最小权限原则
数据库连接账号只给SELECT权限,不给DROP/UPDATE/DELETE权限。即便被注入,破坏面也有限。
sql
sql
1-- 应用连接用这个账号
2GRANT SELECT ON app_db.* TO 'app_user'@'%';
3-- 绝不给:DROP, UPDATE, DELETE, ALTER, CREATE...
4
防线5:开启SQL审计
yaml
yaml
1# application.yml
2mybatis:
3 configuration:
4 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
5
所有最终执行的SQL都会打印到日志,发现异常SQL立刻告警。
七、#也不是万能的:三个你不知道的坑
坑1:#{}不能用于LIKE模糊查询的通配符位置
kotlin
java
1// ❌ 错误:%被转义了,查不到任何数据
2@Select("SELECT * FROM user WHERE name LIKE #{keyword}")
3// keyword = "%Tom%" → 最终查的是 name = '%Tom%'(字面量%),不是模糊匹配
4
5// ✅ 正确:通配符放在$里,值放在#里
6@Select("SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%')")
7
8// ✅ 或者在Java层拼接
9String keyword = "%" + userInput + "%";
10@Select("SELECT * FROM user WHERE name LIKE #{keyword}")
11
坑2:#{}传入null时的行为差异
kotlin
java
1// MySQL中:WHERE name = NULL 永远返回空
2// 因为NULL不等于任何值,包括它自己
3
4// 解决方案:用IFNULL或动态SQL
5@Select("<script>" +
6 "SELECT * FROM user WHERE 1=1 " +
7 "<if test='name != null'>AND name = #{name}</if>" +
8 "</script>")
9
坑3:#{}的类型推断可能出错
less
java
1@Select("SELECT * FROM user WHERE id = #{id}")
2User find(@Param("id") String id); // 传入"123"字符串
3
4// MyBatis会根据String类型调用StringTypeHandler
5// 但如果数据库id是INT,JDBC驱动会自动转换
6// 大多数情况没问题,但跨数据库(如Oracle的NUMBER)可能出现精度丢失
7
建议:参数类型尽量与数据库字段类型一致,或显式指定TypeHandler。
八、终极对照表:什么时候用什么
bash
1┌─────────────────────────────────────────────────────┐
2│ MyBatis参数占位符决策树 │
3├─────────────────────────────────────────────────────┤
4│ │
5│ 参数来自用户输入? │
6│ ├─ YES → 必须用 #{} │
7│ │ (绝不允许${}接收用户输入) │
8│ │ │
9│ └─ NO → 参数用于什么位置? │
10│ ├─ WHERE/SET值 → #{} │
11│ ├─ 表名/列名 → ${} + 白名单校验 │
12│ ├─ ORDER BY → ${} + 白名单校验 │
13│ ├─ LIMIT偏移 → ${} + 范围校验(≥0) │
14│ └─ IN条件 → foreach + #{}(最佳) │
15│ │
16│ 黄金法则:能用#就不用$,非用$不可必校验 │
17└─────────────────────────────────────────────────────┘
18
九、一句话总结
#{}是防弹衣,${}是裸奔。防弹衣能挡99.9%的子弹,但你总有些场景必须裸奔------裸奔可以,但请确保你站在自己的服务器里,而不是站在公网上。
最后送一个防注入口诀,贴在工位上:
1用户输入走井号,
2表名列名才用刀。
3刀口向外必校验,
4校验不过直接抛。
5
下次写MyBatis时,看到${}三个字符,请条件反射式地问自己一句: "这个参数,我能100%确定它不会被人操控吗?" 如果答案有一丝犹豫,请换成#{}。