在MyBatis中,$符号是直接进行字符串拼接的,它不会对传入的参数做任何转义处理,这意味着如果你把用户输入的内容用${}包裹传进SQL语句,攻击者就可以通过构造恶意参数实现SQL注入攻击。正确的做法是:凡是涉及用户输入、动态表名、动态列名等场景,要么用#{}进行预编译参数绑定,要么用MyBatis提供的安全替代方案如<choose>、<if>标签、<bind>元素或者自定义TypeHandler来处理,绝对不要图省事直接用${}拼接外部数据。
很多开发者在写MyBatis映射文件时,经常会遇到需要动态拼接表名、列名、排序字段的情况,这时候第一反应就是用${},因为#{}只能用于参数值的占位,不能用于表名和列名。但问题恰恰出在这里——一旦你把前端传来的参数直接塞进${},SQL注入的大门就打开了。下面我会把这个问题彻底讲透,包括哪些场景必须用${}、哪些场景绝对不能用、以及每种场景的安全替代方案。
一、$符号和#符号的本质区别先把最基础的东西说清楚。MyBatis中#{}和${}的工作原理完全不同。
#{}是预编译参数绑定,MyBatis会把它转换成JDBC的PreparedStatement参数占位符(?),然后通过setString、setInt等方法安全地把值绑进去。数据库驱动会对参数进行转义,攻击者无法通过参数注入恶意SQL。
SELECT * FROM user WHERE id = #{id}
-- 实际执行时等价于:SELECT * FROM user WHERE id = ?
-- 然后通过PreparedStatement.setInt(1, userInput)绑定值
${}是纯粹的字符串替换,MyBatis在解析映射文件时,直接把${}里面的内容原样拼接到SQL语句中,不做任何处理。如果传入的是"1 OR 1=1",那SQL就变成了永远为真的条件。
SELECT * FROM user WHERE id = ${id}
-- 如果id传入 "1 OR 1=1",实际SQL变成:
-- SELECT * FROM user WHERE id = 1 OR 1=1
-- 结果:返回所有用户数据,注入成功
所以核心原则就一条:凡是来自外部的、不可信的数据,一律不要用${}直接拼接进SQL的WHERE条件、SET赋值、VALUES插入等位置。但这不代表${}完全不能用,后面会讲哪些场景可以安全使用。
二、$符号的高危使用场景以下这些场景是SQL注入的重灾区,必须格外注意:
第一,WHERE条件中使用用户输入。比如根据用户名查询,如果写成WHERE name = ${name},攻击者输入"admin' OR '1'='1",整个用户表就暴露了。
-- 危险写法
SELECT * FROM user WHERE username = ${username}
-- 安全写法
SELECT * FROM user WHERE username = #{username}
第二,ORDER BY排序字段。很多人觉得排序字段不会有注入风险,但攻击者可以通过注入UNION SELECT等语句来提取数据。比如传入"id; DROP TABLE user;--",后果不堪设想。
第三,INSERT和UPDATE语句中的列名或表名。虽然列名本身不太容易被注入,但如果列名来自前端配置且没有校验,风险同样存在。
第四,LIKE模糊查询。有人喜欢用${}拼接通配符,比如'${keyword}%',这同样会暴露注入风险。
-- 危险写法
SELECT * FROM product WHERE name LIKE '%${keyword}%'
-- 安全写法
SELECT * FROM product WHERE name LIKE CONCAT('%', #{keyword}, '%')
三、$符号可以安全使用的有限场景
说完禁区,也要客观讲一下${}并非洪水猛兽。在以下场景中,如果你能确保参数来源完全可控,${}是可以使用的:
场景一:动态表名,且表名来自程序内部常量或枚举,而非用户输入。比如你的系统有多租户,表名是tenant_001、tenant_002这种,这些值是后端代码生成的,不是前端传来的。
-- 表名来自后端枚举,安全
SELECT * FROM ${tableName} WHERE id = #{id}
-- Java代码中:String tableName = "tenant_" + tenantId; // tenantId从会话中获取,非用户直接输入
场景二:动态列名,且列名是硬编码在代码逻辑中的白名单。比如用户选择按哪个字段排序,后端用switch语句映射成固定的列名。
-- Java代码中先做白名单校验
String sortColumn;
switch (userInput) {
case "name": sortColumn = "user_name"; break;
case "age": sortColumn = "user_age"; break;
default: sortColumn = "id"; break;
}
-- 然后在XML中使用
ORDER BY ${sortColumn}
场景三:SQL片段复用,比如<include>引用的片段中使用${},但片段本身的参数是内部传递的。
关键判断标准就一个:这个${}里的值,是不是完全由你的后端代码控制、且经过了白名单校验?如果是,可以用;如果有任何一部分来自用户直接输入且没有校验,绝对不能用。
四、动态表名和列名的安全替代方案前面说了${}在动态表名、列名场景下有时不得不用,但必须配合安全措施。以下是几种业界常用的替代和防护方案:
方案一:白名单校验法。这是最简单也最有效的方法。后端维护一个允许的表名列表和列名列表,用户输入先跟白名单比对,匹配上了才允许拼接。
// Java后端白名单校验示例
private static final Set<String> ALLOWED_COLUMNS = Set.of("id", "name", "age", "email");
public String validateColumn(String input) {
if (!ALLOWED_COLUMNS.contains(input)) {
throw new IllegalArgumentException("非法列名");
}
return input;
}
方案二:使用<choose>和<if>标签替代${}。MyBatis的动态SQL标签本身就能实现条件判断,完全不需要字符串拼接。
-- 用动态SQL标签实现多条件查询,无需${}
SELECT * FROM user
WHERE
<if test="name != null">
name = #{name}
</if>
<if test="age != null">
AND age = #{age}
</if>
方案三:使用<bind>元素。MyBatis 3.2.2之后引入的<bind>元素可以在XML内部创建变量,避免直接拼接。
<bind name="pattern" value="'%' + keyword + '%'" />
SELECT * FROM product WHERE name LIKE #{pattern}
方案四:自定义TypeHandler。对于复杂的动态参数处理,可以写一个TypeHandler在Java层面完成参数转换和校验,然后把安全的值传给#{}。
@MappedTypes(String.class)
public class SafeColumnTypeHandler extends BaseTypeHandler<String> {
private static final Set<String> ALLOWED = Set.of("id", "name", "created_at");
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) {
if (!ALLOWED.contains(parameter)) {
throw new IllegalArgumentException("非法列名: " + parameter);
}
ps.setString(i, parameter);
}
// ... 其他方法省略
}
方案五:对于排序字段,推荐在后端用Map映射或者枚举来处理,前端只传序号或枚举值,后端转换成安全的列名。
// 前端传 sortType=1,后端映射
Map<Integer, String> sortMap = Map.of(1, "id", 2, "name", 3, "created_at");
String orderBy = sortMap.getOrDefault(userInput, "id");
// 然后XML中:ORDER BY ${orderBy} -- 此时orderBy是后端映射的,安全
五、MyBatis配置层面的防护措施
除了在映射文件中注意写法,MyBatis本身也提供了一些配置层面的防护:
第一,开启驼峰命名自动映射虽然跟注入无关,但能减少手写列名的需求,间接降低出错概率。
第二,使用MyBatis的拦截器(Interceptor)机制,可以在SQL执行前做统一的参数检查。写一个Plugin拦截Executor的方法,对所有参数进行扫描,发现可疑的SQL关键字就拦截。
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SqlInjectionInterceptor implements Interceptor {
private static final Set<String> DANGEROUS = Set.of("'", ";", "--", "/*", "*/", "DROP", "DELETE", "INSERT", "UPDATE");
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object parameter = invocation.getArgs()[1];
if (parameter != null) {
String paramStr = parameter.toString();
for (String danger : DANGEROUS) {
if (paramStr.contains(danger)) {
throw new SecurityException("检测到潜在SQL注入: " + danger);
}
}
}
return invocation.proceed();
}
}
第三,在application.yml中配置MyBatis的日志输出,生产环境虽然要关掉,但开发和测试阶段开启SQL日志能帮你快速发现拼接问题。
六、实际开发中的最佳实践总结把前面的内容浓缩成几条铁律,方便大家日常开发时对照:
1. 默认用#{},只有在表名、列名等必须动态拼接且经过白名单校验的场景才考虑${}。
2. 任何来自前端、接口参数、URL参数、Cookie的值,在进入SQL之前必须经过校验或转义。
3. 动态SQL优先用<if>、<choose>、<where>、<set>、<foreach>等标签,这些标签内部使用#{}是安全的。
4. 排序、分页参数不要直接拼接,用后端枚举或Map映射中转。
5. LIKE查询用CONCAT函数或<bind>元素,不要用${}拼接通配符。
6. 项目上线前做一次SQL注入专项扫描,可以用MyBatis拦截器配合自动化测试工具。
7. 代码审查时重点关注所有${}的使用点,逐一确认是否有校验逻辑。
说到底,SQL注入的根源不是MyBatis的问题,而是开发者对参数来源缺乏敬畏。${}本身只是一个工具,用对了地方它是方便的,用错了地方就是漏洞。作为开发者,养成"先怀疑、再验证、最后拼接"的习惯,比记住多少条规则都管用。把安全意识融入每一行代码,才是防止SQL注入的根本之道。
