MyBatis SQL注入防护
SQL注入是Web应用中最常见且危害极大的安全漏洞之一。攻击者通过构造恶意SQL语句注入到应用的输入参数中,欺骗数据库执行非预期操作,可能导致数据泄露、篡改、删除甚至服务器权限被窃取。MyBatis作为主流ORM框架,虽然通过自身设计降低了SQL注入风险,但不规范的使用仍会让应用暴露在攻击之下。
# 一、SQL注入基础:原理与危害
在深入MyBatis的防护机制前,我们先明确SQL注入的核心逻辑——本质是“恶意参数破坏SQL语句结构,导致数据库执行非预期逻辑”。
# 1.1 什么是SQL注入?
SQL注入是一种代码注入攻击,攻击者利用应用程序对输入参数的校验不足,将恶意SQL片段注入到SQL语句中,使拼接后的SQL语句被数据库当作合法指令执行。其核心前提是:应用程序将用户输入直接拼接到SQL语句中,未做任何过滤或转义。
# 1.2 SQL注入的典型危害
- 数据泄露:查询敏感数据(如用户密码、银行卡信息),例如通过注入获取全量用户数据;
- 数据篡改/删除:修改或删除核心业务数据(如订单、账户余额),甚至执行
DROP TABLE等破坏性操作; - 权限提升:通过注入获取数据库管理员权限,进一步控制服务器;
- 服务器入侵:结合数据库特性(如MySQL的
LOAD_FILE、INTO OUTFILE)读取/写入服务器文件,植入恶意代码。
# 1.3 典型SQL注入案例(原生JDBC对比)
在原生JDBC中,直接拼接用户输入的SQL语句是导致注入的直接原因。例如用户登录场景:
// 危险写法:直接拼接用户输入
String username = request.getParameter("username");
String password = request.getParameter("password");
String sql = "SELECT * FROM user WHERE username = '" + username + "' AND password = '" + password + "'";
// 当攻击者输入 username = "admin' OR '1'='1" 时,拼接后的SQL为:
// SELECT * FROM user WHERE username = 'admin' OR '1'='1' AND password = 'xxx'
// 由于 '1'='1' 恒成立,无需正确密码即可登录成功
2
3
4
5
6
7
而安全的写法是使用PreparedStatement预编译SQL,将参数与SQL语句分离:
// 安全写法:预编译SQL,参数占位符
String sql = "SELECT * FROM user WHERE username = ? AND password = ?";
PreparedStatement pstmt = connection.prepareStatement(sql);
pstmt.setString(1, username); // 参数单独设置,自动转义
pstmt.setString(2, password);
ResultSet rs = pstmt.executeQuery();
2
3
4
5
6
预编译的核心优势是:SQL语句的结构在编译阶段就已确定,用户输入仅作为参数填充到占位符中,无法改变SQL的原有逻辑,从而从根源上避免注入。而MyBatis的核心防护机制,正是基于预编译实现的。
# 二、MyBatis中SQL注入的风险源头:#{} vs ${}
MyBatis提供了两种参数占位符:#{}和${}。两者的核心区别在于“是否触发预编译”,这也是MyBatis中SQL注入风险的核心源头。
# 2.1 #{}与${}的本质区别
我们从“解析方式”“参数处理”“安全程度”三个维度对比两者:
| 对比维度 | #{}(预编译占位符) | ${}(字符串拼接) |
|---|---|---|
| 解析方式 | MyBatis将其解析为PreparedStatement的?占位符,触发JDBC预编译 | MyBatis直接将参数值作为字符串拼接到SQL语句中,不触发预编译 |
| 参数处理 | 自动对参数进行类型转换和特殊字符转义(如单引号'转义为'') | 直接拼接原始参数值,无任何转义处理 |
| 安全程度 | 安全,从根源避免SQL注入 | 危险,存在SQL注入风险(除非参数已做严格校验) |
| 适用场景 | 绝大多数场景:查询条件参数、插入/更新的字段值等 | 特殊场景:表名、列名动态拼接、排序字段(ORDER BY)、分组字段(GROUP BY)等 |
# 2.2 风险验证:${}导致SQL注入的实战案例
我们以“用户登录”和“动态排序查询”两个常见场景为例,验证${}的注入风险。
# 场景1:用户登录(直接拼接用户名)
Mapper.xml危险写法(使用${}拼接用户名):
<!-- UserMapper.xml:危险写法 -->
<select id="login" resultType="com.example.entity.User">
SELECT id, username, password FROM user
WHERE username = '${username}' AND password = '${password}'
</select>
2
3
4
5
攻击演示:
当攻击者输入:
- username =
"admin' OR '1'='1" - password =
"任意值"
拼接后的SQL语句:
SELECT id, username, password FROM user
WHERE username = 'admin' OR '1'='1' AND password = '任意值'
2
由于'1'='1'恒成立,OR条件使整个查询条件成立,攻击者无需正确密码即可登录成功,甚至可以通过注入"admin' OR 1=1 UNION SELECT * FROM user--"获取全量用户数据。
# 场景2:动态排序(拼接排序字段)
很多业务需要支持“动态排序”(如按时间、按金额排序),若直接用${}拼接排序字段,会存在注入风险:
<!-- UserMapper.xml:危险写法 -->
<select id="listUser" resultType="com.example.entity.User">
SELECT id, username, create_time FROM user
ORDER BY ${sortField} ${sortOrder}
</select>
2
3
4
5
攻击演示:
当攻击者输入:
- sortField =
"create_time; DROP TABLE user--" - sortOrder =
"ASC"
拼接后的SQL语句:
SELECT id, username, create_time FROM user
ORDER BY create_time; DROP TABLE user-- ASC
2
其中;用于分隔SQL语句,--用于注释后续内容,导致数据库先执行查询,再执行DROP TABLE user,直接删除核心表,造成毁灭性损失。
# 2.3 关键结论
MyBatis中SQL注入的核心风险源是${}的不规范使用——由于其直接拼接字符串的特性,攻击者可通过构造恶意参数改变SQL语句结构。而#{}通过预编译机制从根源避免了注入风险,是MyBatis的默认安全方案。
# 三、MyBatis核心防护机制:预编译与参数绑定
MyBatis的核心防护能力来源于对JDBCPreparedStatement预编译机制的封装。我们从“解析流程”“参数处理”“底层原理”三个层面拆解其防护逻辑。
# 3.1 #{}的解析与预编译流程
当MyBatis解析包含#{}的Mapper语句时,会执行以下流程:
- SQL语句预处理:MyBatis将
#{paramName}替换为?,生成预编译SQL语句(如SELECT * FROM user WHERE username = ?); - 预编译请求:MyBatis通过JDBC将预编译SQL发送给数据库,数据库对SQL语句进行编译(确定执行计划),并缓存编译结果;
- 参数绑定与执行:MyBatis将用户输入的参数值通过
PreparedStatement.setXxx()方法绑定到?占位符上,数据库直接使用缓存的执行计划执行SQL,参数仅作为数据填充,不参与SQL语句的解析。
核心优势:SQL语句的结构在编译阶段就已固定,用户输入无论是什么内容,都只能作为参数填充,无法改变SQL的原有逻辑,从而彻底杜绝注入。
# 3.2 #{}的参数转义机制
除了预编译,MyBatis还会对#{}传入的参数进行自动转义,进一步保障安全。例如,当参数中包含单引号'时,MyBatis会自动将其转义为''(数据库的转义规则),避免参数中的单引号破坏SQL语句的引号配对。
示例:若用户输入username = "admin'",使用#{username}的SQL会被处理为:
SELECT * FROM user WHERE username = 'admin'''
此时数据库会将''解析为单个单引号(字符串内的转义),查询条件变为“用户名等于admin'”,而非破坏SQL结构。
# 3.3 底层源码佐证:#{}的预编译处理
MyBatis在解析#{}时,会通过ParameterMapping封装参数信息,并生成预编译SQL。核心源码片段来自XmlStatementBuilder和BoundSql:
// MyBatis解析#{}的核心逻辑(简化)
public class XmlStatementBuilder {
private void processSelectKeyNodes(String id, SelectKeyGenerator keyGenerator, XmlNode nodeToHandle, String parameterTypeClass, LanguageDriver langDriver) {
// 解析Mapper中的SQL语句,将#{xxx}替换为?
BoundSql boundSql = langDriver.createBoundSql(configuration, sql, parameterMappings, parameterObject);
// 生成PreparedStatement所需的预编译SQL
String sql = boundSql.getSql(); // 此时sql中的#{}已被替换为?
}
}
// BoundSql类:封装预编译后的SQL和参数映射
public class BoundSql {
private final String sql; // 预编译后的SQL(含?占位符)
private final List<ParameterMapping> parameterMappings; // 参数映射信息
// ...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
从源码可见,MyBatis在解析阶段就已将#{}转换为预编译占位符,后续通过PreparedStatement执行,确保参数与SQL分离。
# 四、MyBatis SQL注入防护实战:安全写法与风险规避
基于以上原理,我们针对MyBatis的常见使用场景,给出“危险写法”与“安全写法”的对比,并重点说明${}的安全使用方式(无法避免使用时)。
# 4.1 基础场景:查询/新增/更新参数
此类场景是最常见的参数传递场景,必须使用#{},严禁使用${}。
<!-- 场景:根据ID查询用户 -->
<!-- 危险写法 -->
<select id="getUserById" resultType="com.example.entity.User">
SELECT * FROM user WHERE id = ${id} <!-- 风险:直接拼接ID,若ID为字符串类型可注入 -->
</select>
<!-- 安全写法 -->
<select id="getUserById" resultType="com.example.entity.User">
SELECT * FROM user WHERE id = #{id} <!-- 预编译,自动转义,安全 -->
</select>
2
3
4
5
6
7
8
9
10
<!-- 场景:新增用户 -->
<!-- 危险写法 -->
<insert id="addUser" parameterType="com.example.entity.User">
INSERT INTO user(username, password) VALUES('${username}', '${password}')
</insert>
<!-- 安全写法 -->
<insert id="addUser" parameterType="com.example.entity.User">
INSERT INTO user(username, password) VALUES(#{username}, #{password})
</insert>
2
3
4
5
6
7
8
9
10
# 4.2 特殊场景1:动态表名/列名(必须用${}时)
当需要动态指定表名(如分表场景:user_202401、user_202402)或列名时,无法使用#{}}(预编译的表名/列名会被当作字符串处理,导致SQL语法错误),此时必须使用${},但需通过“白名单校验”规避风险。
示例:分表查询用户数据
<!-- UserMapper.xml:使用${}拼接表名,但需在Java代码中做白名单校验 -->
<select id="listUserByTable" resultType="com.example.entity.User">
SELECT * FROM user_${month} <!-- month为分表后缀,如202401 -->
WHERE status = #{status}
</select>
2
3
4
5
安全防护:在Java代码中对month参数做白名单校验,确保其只能是合法的分表后缀:
public List<User> listUserByTable(String month, Integer status) {
// 白名单校验:仅允许202401-202412的分表后缀
List<String> validMonths = Arrays.asList("202401", "202402", ..., "202412");
if (!validMonths.contains(month)) {
throw new IllegalArgumentException("非法的分表参数");
}
// 校验通过后调用Mapper
return userMapper.listUserByTable(month, status);
}
2
3
4
5
6
7
8
9
核心原则:动态表名/列名的参数必须是有限可选值,通过白名单校验确保参数合法,禁止直接接收用户输入的原始值。
# 4.3 特殊场景2:动态排序(ORDER BY)
排序字段(sortField)和排序方向(sortOrder)无法使用#{}}(会导致SQL语法错误),必须使用${},防护方案同样是“白名单校验”。
<!-- UserMapper.xml:动态排序 -->
<select id="listUser" resultType="com.example.entity.User">
SELECT id, username, create_time, amount FROM user
ORDER BY ${sortField} ${sortOrder}
</select>
2
3
4
5
安全防护:在Java代码中对sortField和sortOrder做白名单校验:
public List<User> listUser(String sortField, String sortOrder) {
// 1. 排序字段白名单:仅允许create_time、amount
List<String> validSortFields = Arrays.asList("create_time", "amount");
if (!validSortFields.contains(sortField)) {
sortField = "create_time"; // 默认值,避免注入
}
// 2. 排序方向白名单:仅允许ASC、DESC
List<String> validSortOrders = Arrays.asList("ASC", "DESC");
if (!validSortOrders.contains(sortOrder)) {
sortOrder = "ASC"; // 默认值
}
// 调用Mapper
return userMapper.listUser(sortField, sortOrder);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
补充:若排序字段较多,可通过正则表达式校验(如仅允许字母、下划线组成),进一步缩小参数范围:
if (!sortField.matches("^[a-zA-Z_]+$")) {
throw new IllegalArgumentException("非法的排序字段");
}
2
3
# 4.4 特殊场景3:LIKE查询(避免错误使用${})
LIKE查询是高频场景,很多开发者会错误地使用${}拼接模糊查询条件,导致注入风险。正确的做法是使用#{}}配合字符串拼接(在SQL中使用CONCAT函数)。
<!-- 场景:根据用户名模糊查询 -->
<!-- 危险写法 -->
<select id="searchUserByName" resultType="com.example.entity.User">
SELECT * FROM user WHERE username LIKE '%${username}%' <!-- 风险:直接拼接 -->
</select>
<!-- 安全写法1:SQL中使用CONCAT函数 -->
<select id="searchUserByName" resultType="com.example.entity.User">
SELECT * FROM user WHERE username LIKE CONCAT('%', #{username}, '%')
</select>
<!-- 安全写法2:Java代码中拼接%,Mapper中使用#{}}
<select id="searchUserByName" resultType="com.example.entity.User">
SELECT * FROM user WHERE username LIKE #{username}
</select>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
对应的Java代码:
public List<User> searchUserByName(String username) {
// Java代码中拼接%,参数仍通过#{}}传递
String searchParam = "%" + username + "%";
return userMapper.searchUserByName(searchParam);
}
2
3
4
5
# 4.5 特殊场景4:IN查询(避免${}拼接多个参数)
IN查询需要传递多个参数(如IN (1,2,3)),若直接用${}拼接参数列表,会存在注入风险。正确的做法是使用MyBatis的<foreach>标签配合#{}}。
<!-- 场景:根据多个ID查询用户 -->
<!-- 危险写法 -->
<select id="listUserByIds" resultType="com.example.entity.User">
SELECT * FROM user WHERE id IN (${ids}) <!-- 风险:ids若为"1,2; DROP TABLE user"会注入 -->
</select>
<!-- 安全写法:使用<foreach>标签 -->
<select id="listUserByIds" resultType="com.example.entity.User">
SELECT * FROM user WHERE id IN
<foreach collection="ids" item="id" open="(" close=")" separator=",">
#{id}
</foreach>
</select>
2
3
4
5
6
7
8
9
10
11
12
13
对应的Java代码(参数为Lis<tInteger> ids):
public List<User> listUserByIds(List<Integer> ids) {
return userMapper.listUserByIds(ids);
}
2
3
MyBatis会自动将List中的元素通过#{}}解析为预编译参数,生成IN (?, ?, ?)的安全SQL。
# 五、全链路防护:不止于编码的安全保障
SQL注入防护是全链路的工作,除了规范使用#{}和${},还需要从“输入校验”“配置加固”“依赖升级”“日志监控”等多个维度构建防护体系。
# 5.1 输入参数校验:前端+后端双重拦截
攻击者的恶意参数首先要经过前端和后端的校验拦截,减少恶意参数进入Mapper层的可能性:
- 前端校验:限制输入长度、过滤特殊字符(如
'、;、--、UNION等),给出友好的输入提示; - 后端校验:使用校验框架(如Hibernate Validator)对参数进行类型、格式、范围校验,例如:
public class UserQuery {
@NotNull(message = "用户名不能为空")
@Size(max = 20, message = "用户名长度不能超过20")
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "用户名仅允许字母、数字、下划线")
private String username;
// 其他参数...
}
2
3
4
5
6
7
8
# 5.2 MyBatis配置加固
通过MyBatis的核心配置,进一步提升安全性:
- 开启参数类型校验:在MyBatis配置文件中开启
mapUnderscoreToCamelCase(驼峰命名转换)的同时,确保参数类型与数据库字段类型匹配,避免类型转换导致的注入风险; - 禁用unsafe的配置:避免使用
defaultStatementType="STATEMENT"(默认是PREPARED),STATEMENT会禁用预编译,强制MyBatis使用字符串拼接; - 开启SQL日志监控:配置
logImpl=SLF4J,打印执行的SQL语句和参数,便于及时发现异常的SQL注入行为。
<!-- mybatis-config.xml 配置加固 -->
<configuration>
<settings>
<!-- 开启驼峰命名转换 -->
<setting name="mapUnderscoreToCamelCase" value="true"/>
<!-- 禁用STATEMENT,强制使用PREPARED预编译 -->
<setting name="defaultStatementType" value="PREPARED"/>
<!-- 开启SQL日志,便于监控 -->
<setting name="logImpl" value="SLF4J"/>
</settings>
</configuration>
2
3
4
5
6
7
8
9
10
11
# 5.3 数据库权限最小化
即使发生SQL注入,最小化的数据库权限也能降低攻击危害:
- 应用程序连接数据库的账号,仅授予“必要权限”(如SELECT、INSERT、UPDATE、DELETE),严禁授予DROP、ALTER、CREATE等破坏性权限;
- 不同业务模块使用不同的数据库账号,避免一个账号泄露导致全库数据风险;
- 禁止使用数据库管理员(root、sa等)账号连接应用。
# 5.4 依赖包升级:避免框架漏洞
及时升级MyBatis及相关依赖包,修复已知的安全漏洞:
- MyBatis核心包:升级到最新稳定版本(如3.5.16+),避免旧版本中的解析漏洞;
- JDBC驱动包:升级到对应数据库的最新驱动版本,修复驱动层的预编译漏洞;
- 使用依赖管理工具(Maven、Gradle)定期检查依赖漏洞(如通过Dependabot、OWASP Dependency Check)。
# 5.5 安全审计与监控
通过日志监控和安全审计,及时发现并阻断SQL注入攻击:
- 记录所有执行的SQL语句和参数,重点监控包含特殊字符(
'、;、UNION、DROP等)的SQL; - 使用WAF(Web应用防火墙)拦截常见的SQL注入攻击特征(如OWASP Top 10中的注入攻击特征);
- 定期进行安全渗透测试,模拟攻击者行为,排查潜在的注入漏洞。
# 六、常见问题与避坑指南
在实际开发中,很多SQL注入风险源于开发者的“疏忽”或“误解”。以下是常见问题及解决方案:
# 6.1 问题1:“用了#{}就一定安全吗?”
不一定。若在同一个SQL语句中混用#{}和${},仍可能存在风险。例如:
<!-- 危险:混用#{}和${} -->
<select id="unsafeQuery" resultType="com.example.entity.User">
SELECT ${column} FROM user WHERE id = #{id}
</select>
2
3
4
解决方案:严格控制${}的使用场景,对所有通过${}传递的参数做白名单校验。
# 6.2 问题2:“MyBatis的注解方式(@Select)是否更安全?”
注解方式与XML方式的安全机制完全一致,核心仍取决于使用#{}还是${}。例如:
// 危险写法(注解中使用${})
@Select("SELECT * FROM user WHERE username = '${username}'")
User login(@Param("username") String username, @Param("password") String password);
// 安全写法(注解中使用#{})
@Select("SELECT * FROM user WHERE username = #{username} AND password = #{password}")
User login(@Param("username") String username, @Param("password") String password);
2
3
4
5
6
7
# 6.3 问题3:“存储过程中的参数会导致注入吗?”
会。若存储过程中使用动态SQL拼接,即使MyBatis中使用#{}传递参数,仍可能存在注入风险。例如存储过程中:
-- 危险的存储过程:内部拼接SQL
CREATE PROCEDURE searchUser(IN username VARCHAR(20))
BEGIN
SET @sql = CONCAT('SELECT * FROM user WHERE username = ''', username, '''');
PREPARE stmt FROM @sql;
EXECUTE stmt;
END;
2
3
4
5
6
7
解决方案:存储过程内部也应使用预编译参数,避免字符串拼接;或直接在MyBatis中实现业务逻辑,避免复杂存储过程。
# 6.4 问题4:“批量插入/更新时用${}是否安全?”
不安全。批量操作若使用${}拼接参数,风险与单条操作一致。正确的做法是使用MyBatis的<foreach>标签配合#{}:
<!-- 批量插入安全写法 -->
<insert id="batchAddUser" parameterType="java.util.List">
INSERT INTO user(username, password)
VALUES
<foreach collection="list" item="user" separator=",">
(#{user.username}, #{user.password})
</foreach>
</insert>
2
3
4
5
6
7
8
# 七、总结:MyBatis SQL注入防护核心原则
MyBatis的SQL注入防护,核心是“切断恶意参数对SQL语句结构的破坏”,关键在于理解并规范使用#{}和${}。总结为以下3个核心原则:
- 优先使用#{},杜绝滥用${}:除了动态表名、列名、排序字段等特殊场景,所有参数传递都必须使用#{},利用预编译机制从根源避免注入;
- ${}必须配合白名单校验:若无法避免使用${},必须对参数进行严格的白名单校验(或正则校验),确保参数是预期的合法值,禁止直接接收用户原始输入;
- 构建全链路防护体系:防护不止于编码,还需覆盖输入校验、配置加固、数据库权限控制、依赖升级、日志监控等全链路环节,形成“多层防御”。