MyBatis中防止SQL注入,核心就一句话:永远用#{}而不是${}。#{}是预编译参数绑定,会把用户输入当作字符串字面值处理,数据库驱动会自动转义特殊字符;${}是字符串拼接,直接把内容塞进SQL语句里,等于给攻击者开了后门。很多开发人员踩坑就踩在这两个符号的混淆使用上,尤其是在动态SQL拼接、order by、in查询这些场景里,一不小心就写出了SQL注入漏洞。下面我把所有常见的坑、正确写法、以及底层原理一次性讲透。
一、#{}和${}的本质区别到底是什么
先把最基础的东西钉死。MyBatis在执行SQL时,#{}会生成预编译语句(PreparedStatement),参数以占位符?的形式传给JDBC驱动,驱动层负责做转义和类型处理。而${}是在SQL映射文件解析阶段就直接做字符串替换,把值原样拼进SQL文本,然后再交给数据库执行。简单说,#{}是"参数化查询",${}是"文本拼接"。
// 正确写法:使用#{},安全
SELECT * FROM user WHERE id = #{id}
// 生成的SQL:SELECT * FROM user WHERE id = ?
// 危险写法:使用${},存在注入风险
SELECT * FROM user WHERE id = ${id}
// 生成的SQL:SELECT * FROM user WHERE id = 1 OR 1=1二、最常见的五个踩坑场景
1. 动态表名、列名用了${}但没做校验
表名和列名不能用#{},因为#{}会给值加引号,导致SQL语法错误。所以很多人被迫用${},这时候如果直接拼用户输入就完了。正确做法是:在Java代码层做白名单校验,只允许合法的表名和列名通过,再传到Mapper里用${}引用。
// Java层校验
private static final Set<String> ALLOWED_COLUMNS = Set.of("name", "age", "email");
public void queryByColumn(String column, String value) {
if (!ALLOWED_COLUMNS.contains(column)) {
throw new IllegalArgumentException("非法列名");
}
mapper.selectByColumn(column, value);
}
// Mapper.xml
<select id="selectByColumn" resultType="User">
SELECT * FROM user ORDER BY ${column}
</select>2. order by、group by中直接用${}拼用户输入
排序字段和分组字段同样不能用#{},因为#{}会把字段名当成字符串值加引号。但如果直接把前端传来的sortField拼进去,攻击者可以注入恶意SQL片段。解决方案和上面一样:白名单校验字段名,或者在Java层用枚举限定可选字段。
// 使用枚举限定排序字段
public enum SortField {
NAME, AGE, CREATE_TIME
}
// Mapper.xml
<select id="selectWithOrder" resultType="User">
SELECT * FROM user ORDER BY ${sortField}
</select>3. in查询中用${}拼接整个列表
这是重灾区。很多人想动态传入一个id列表,直接用${}把整个列表拼成"1,2,3"塞进去。这样不仅有注入风险,而且当列表为空时SQL会报错。MyBatis提供了<foreach>标签配合#{}来安全处理in查询。
// 错误写法
SELECT * FROM user WHERE id IN (${idList})
// 正确写法
<select id="selectByIds" resultType="User">
SELECT * FROM user
WHERE id IN
<foreach collection="idList" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>4. like模糊查询中错误使用${}
模糊查询需要拼接通配符%,有人图省事直接用${}把整个搜索关键词拼进去。正确做法是在#{}内部用concat函数或者直接在参数里带上%,但一定要用#{}包裹。
// 错误写法
SELECT * FROM user WHERE name LIKE '%${keyword}%'
// 正确写法一:参数中带通配符
// Java: String keyword = "%" + userInput + "%";
<select id="searchByName" resultType="User">
SELECT * FROM user WHERE name LIKE #{keyword}
</select>
// 正确写法二:使用concat
<select id="searchByName" resultType="User">
SELECT * FROM user WHERE name LIKE CONCAT('%', #{keyword}, '%')
</select>5. 动态SQL中<if>标签的test属性误用${}
有些人在<if test="...">里用${}引用参数做判断,这其实不会直接导致注入,但会让逻辑变得混乱且难以维护。test属性里应该用#{}或者直接用OGNL表达式引用参数名。更关键的是,不要在test里做复杂的字符串拼接判断,保持简单的参数比较。
// 不推荐
<if test="'${type}' == 'admin'">
// 推荐
<if test="type == 'admin'">三、为什么#{}能防注入——底层原理讲清楚
当你使用#{}时,MyBatis会把SQL发送给数据库的方式变成预编译。以MySQL为例,JDBC驱动会发送两步操作:第一步发送带?占位符的SQL模板,第二步单独发送参数值。数据库在编译阶段就确定了SQL结构,参数值只是数据,不会被当作SQL语法的一部分来解析。即使参数值里包含单引号、分号、注释符等特殊字符,驱动层都会做转义处理。
而${}是在MyBatis解析XML映射文件时就完成了字符串替换,最终发给数据库的是一条完整的、已经拼好的SQL语句。数据库拿到的就是一条普通SQL,它无法区分哪部分是"原本的SQL结构"、哪部分是"用户输入的数据",所以特殊字符会被当作SQL语法来执行,注入就此发生。
四、除了符号选择,还有哪些配套防护措施
1. 参数类型要明确指定
在Mapper接口方法中明确参数类型,使用@Param注解给参数命名。这样不仅代码可读性好,而且能避免参数绑定错误导致的意外行为。
public interface UserMapper {
List<User> selectByCondition(@Param("name") String name, @Param("age") Integer age);
}2. 输入验证不能省
即使使用了#{},也不代表可以完全不做输入验证。#{}防的是SQL注入,但防不了业务逻辑层面的脏数据。比如年龄字段传个负数、邮箱字段传个超长字符串,这些都需要在Service层做校验。建议使用JSR 303/JSR 380注解做参数校验。
public class UserQueryDTO {
@NotBlank(message = "姓名不能为空")
private String name;
@Min(value = 0, message = "年龄不能为负数")
@Max(value = 150, message = "年龄不合法")
private Integer age;
@Email(message = "邮箱格式不正确")
private String email;
}3. 开启MyBatis日志监控
在开发和测试环境开启MyBatis的SQL日志输出,确认生成的SQL语句确实使用了预编译占位符而不是直接拼接。在application.yml中配置:
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl4. 数据库权限最小化
应用连接数据库的账号只给必要的权限,不要用root账号跑业务。即使出现注入漏洞,最小化权限也能限制攻击者能做的事情。比如只给SELECT、INSERT、UPDATE权限,不给DROP、ALTER权限。
五、一个容易被忽视的细节:#{}在某些场景下也不是万能的
虽然#{}是防注入的金标准,但有一种情况需要特别注意:当你把#{}用在LIMIT子句中时,某些数据库驱动对LIMIT的参数绑定支持不一致。比如MySQL的JDBC驱动在旧版本中对LIMIT的?占位符支持有问题,这时候需要确认驱动版本。另外,如果你在#{}里传的是一个包含SQL关键字的字符串(比如用户输入"DROP TABLE"),#{}会把它当成普通字符串值处理,不会执行,所以是安全的。但如果你的业务逻辑是把这个值拼到其他地方再执行,那就另当别论了。
六、总结:一张表记住所有要点
把核心规则浓缩一下:能用#{}的地方绝对不用${};必须用${}的地方(表名、列名、order by字段)一定要做白名单校验;in查询用<foreach>+#{};like查询用#{}+参数带通配符或concat;输入验证和最小权限是最后一道防线。把这几条刻进肌肉记忆,SQL注入基本就跟你的项目无缘了。
最后再强调一次,MyBatis的#{}和${}不是"差不多"的关系,而是"安全"和"危险"的关系。很多线上事故就是因为一个${}的疏忽,导致整个数据库被拖库。作为开发者,养成每次写动态SQL都先问自己"这里能不能用#{}"的习惯,比什么安全工具都管用。
