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

相关推荐
Rust研习社2 小时前
组合真的优于继承吗?为什么 Rust 和 Go 都拥抱组合舍弃继承?
后端·rust·编程语言
IT_陈寒2 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
CaffeinePro3 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax3 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH3 小时前
Koa和Express的区别
后端
MariaH3 小时前
Koa框架的使用
后端
luckdewei4 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某6 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy6 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom6 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github