// 某个深夜,实习生小王写了这行代码:
@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在底层到底干了什么?
// org.apache.ibatis.mapping.ParameterMapping
public class ParameterMapping {
private final String property;
private final TypeHandler<?> typeHandler;
private final JdbcType jdbcType;
// ...
}
MyBatis把#{name}解析成一个ParameterMapping对象,记录了参数名、类型处理器、JDBC类型。
// 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中已经没有具体值了,只剩一个问号。
// 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 + "'" ──→ 直接执行 ──→ 注入?
@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' -- 注释符后面全被吃掉,密码验证直接消失
一行${},整个登录系统形同虚设。
@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 --
用户表和管理员表的账号密码,一次性全泄露。
@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)" -- 通过报错信息推断数据库版本,进而构造更复杂的攻击
任何${},无论用在哪里,都是定时炸弹。
说完危险,必须说真话——有些场景下,#根本无法替代$ :
// 按月分表: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语法规定表名必须是字面量。
@Select("SELECT * FROM user ORDER BY ${orderBy} ${orderDir}")
List<User> list(@Param("orderBy") String orderBy, @Param("orderDir") String orderDir);
ORDER BY后面必须跟列名,不能用?值。
// 错误写法:会把整个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都走#{}预编译,安全!
@Select("SELECT * FROM user LIMIT ${offset}, ${size}")
List<User> page(@Param("offset") int offset, @Param("size") int size);
LIMIT后面的数字不能用占位符,必须字面量。
既然$不得不用,那就把风险降到最低。请严格遵守以下五条:
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);
}
原则:凡是${}接收的参数,必须经过白名单校验,一个字符都不能信。
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用反引号包裹
}
// ❌ 绝对禁止
@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); // 走预编译,安全
用户输入 → #{};系统内部参数(如配置项)→ ${}(且需白名单)
数据库连接账号只给SELECT权限,不给DROP/UPDATE/DELETE权限。即便被注入,破坏面也有限。
-- 应用连接用这个账号 GRANT SELECT ON app_db.* TO 'app_user'@'%'; -- 绝不给:DROP, UPDATE, DELETE, ALTER, CREATE...
# application.yml
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
所有最终执行的SQL都会打印到日志,发现异常SQL立刻告警。
// ❌ 错误:%被转义了,查不到任何数据
@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}")
// MySQL中:WHERE name = NULL 永远返回空
// 因为NULL不等于任何值,包括它自己
// 解决方案:用IFNULL或动态SQL
@Select("<script>" +
"SELECT * FROM user WHERE 1=1 " +
"<if test='name != null'>AND name = #{name}</if>" +
"</script>")
@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%确定它不会被人操控吗?" 如果答案有一丝犹豫,请换成#{}。