MyBatis的$和#区别:你以为防注入就够了?

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%确定它不会被人操控吗?" 如果答案有一丝犹豫,请换成#{}

相关推荐
小强19881 小时前
Java程序员必知的4种引用类型:强、软、弱、虚——彻底告别内存泄漏
后端
鱼人1 小时前
Spring Boot启动过程中偷偷干了什么?手撕run方法源码
后端
长大19881 小时前
MySQL + Redis + Caffeine:Java后端通用三级缓存架构实战
后端
乘风破浪酱524361 小时前
别再乱用Redisson分布式锁了!这可能是你见过最标准的教程(附完整代码)
后端
兔子零10241 小时前
当 Codex 成为主力,软件工程的重心已经变了
前端·后端·架构
用户6757049885022 小时前
别再死记硬背了!一文扒光 I/O 多路复用的底裤(Epoll/Select/Poll)
后端
牛奶2 小时前
网关是怎么当"门卫"的?
前端·后端·负载均衡
悟空聊架构2 小时前
100多G数据同步引发的MySQL集群“连环炸”,我是如何一步步恢复的? - 墨天轮
后端·架构