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