1. 为什么Mybatis开发者必须掌握SQL注入防御
我第一次接触SQL注入漏洞是在五年前的一个深夜。当时刚上线的电商系统突然出现异常订单,经过排查发现攻击者通过商品搜索框注入了恶意SQL语句,直接删除了用户表的部分数据。那次事故让我深刻意识到,任何使用数据库的应用都必须把SQL注入防御作为基本功。
Mybatis作为Java生态中最流行的ORM框架之一,虽然通过预编译机制提供了基础防护,但开发者的不当使用仍然会引入严重安全隐患。根据OWASP 2022年报告,注入攻击(包括SQL注入)仍位居Web应用安全风险Top 10榜首。而国内某大型漏洞众测平台数据显示,使用Mybatis的系统中有23%的漏洞与SQL注入相关。
1.1 SQL注入的典型危害场景
通过分析近年公开的安全事件,Mybatis环境下的SQL注入主要发生在以下场景:
- 动态排序场景:前端传入
order by参数直接拼接
java复制// 危险写法
@Select("SELECT * FROM users ORDER BY ${sortField}")
List<User> getUsersSorted(@Param("sortField") String sortField);
- 表名/列名动态化:多租户系统中动态指定表名
java复制// 风险示例
@Update("UPDATE ${tableName} SET status=#{status} WHERE id=#{id}")
int updateRecord(@Param("tableName") String tableName, ...);
- IN条件动态生成:接收前端传入的ID集合直接拼接
xml复制<!-- 漏洞写法 -->
<select id="getByIds">
SELECT * FROM products WHERE id IN (${ids})
</select>
1.2 Mybatis的两种SQL构建机制
理解Mybatis处理SQL的底层原理是防御注入的关键:
| 机制类型 | 符号表示 | 处理时机 | 安全性 | 适用场景 |
|---|---|---|---|---|
| 静态预处理 | #{} |
SQL预编译阶段替换为占位符 | 安全 | 普通参数值 |
| 动态拼接 | ${} |
运行时直接文本替换 | 危险 | 表名/列名等非值内容 |
关键区别:
#{}会经过JDBC预编译处理,参数值会被正确转义;${}是简单的字符串替换,相当于直接拼接SQL片段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Mybatis中SQL注入的六种典型场景与修复方案
2.1 动态排序导致的注入漏洞
漏洞示例:
java复制@Select("SELECT * FROM news ORDER BY ${sortField} DESC")
List<News> getNewsList(@Param("sortField") String sortField);
攻击者可传入sortField=update_time; DROP TABLE news--实现注入。
安全方案:
java复制// 方案1:白名单校验
private static final Set<String> ALLOWED_SORT_FIELDS =
Set.of("create_time", "update_time", "view_count");
public List<News> getNewsListSafe(String sortField) {
if (!ALLOWED_SORT_FIELDS.contains(sortField)) {
sortField = "create_time";
}
return mapper.getNewsList(sortField);
}
// 方案2:使用MyBatis的OGNL表达式
@Select("SELECT * FROM news ORDER BY ${@com.example.util.SortHelper@checkSortField(sortField)} DESC")
List<News> getNewsListSafe(@Param("sortField") String sortField);
2.2 动态表名/列名的风险控制
危险写法:
xml复制<select id="queryFromTable">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
安全实践:
java复制// 表名枚举校验
public enum SafeTables {
USER("t_user"),
PRODUCT("t_product");
private final String tableName;
public static String getSafeName(String input) {
return Arrays.stream(values())
.filter(e -> e.name().equalsIgnoreCase(input))
.findFirst()
.orElse(USER)
.getTableName();
}
}
// 使用安全的表名获取方式
@Select("SELECT * FROM ${@com.example.SafeTables@getSafeName(tableName)} WHERE id=#{id}")
User getById(@Param("tableName") String tableName, @Param("id") Long id);
2.3 IN条件中的注入隐患
错误示范:
java复制@Select("SELECT * FROM products WHERE id IN (${ids})")
List<Product> getByIds(@Param("ids") String ids);
// 调用:getByIds("1,2,3"); 但可能传入"1) OR 1=1 --"
正确做法:
xml复制<select id="getByIds">
SELECT * FROM products
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
2.4 模糊查询的安全实现
风险写法:
java复制@Select("SELECT * FROM users WHERE name LIKE '%${name}%'")
List<User> searchUsers(@Param("name") String name);
安全方案:
java复制// 方案1:使用CONCAT函数
@Select("SELECT * FROM users WHERE name LIKE CONCAT('%',#{name},'%')")
// 方案2:在参数中处理通配符
List<User> searchUsers(String name) {
return mapper.searchUsers("%" + name + "%");
}
2.5 动态SQL中的条件注入
问题代码:
xml复制<select id="search">
SELECT * FROM books
WHERE 1=1
<if test="title != null">
AND title LIKE '%${title}%'
</if>
</select>
修复方案:
xml复制<select id="searchSafe">
SELECT * FROM books
<where>
<if test="title != null">
AND title LIKE CONCAT('%',#{title},'%')
</if>
</where>
</select>
2.6 注解与XML混用的陷阱
危险组合:
java复制@SelectProvider(type = BookSqlBuilder.class, method = "buildQuery")
List<Book> queryBooks(@Param("filter") String filter);
class BookSqlBuilder {
public String buildQuery(String filter) {
return "SELECT * FROM books WHERE " + filter; // 直接拼接
}
}
安全改造:
java复制class BookSqlBuilder {
public String buildQuery(Map<String, Object> params) {
return new SQL() {{
SELECT("*");
FROM("books");
WHERE(params.get("filter")); // 仍需对filter内容做严格校验
}}.toString();
}
}
3. 深度防御:Mybatis配置层安全加固
3.1 启用Mybatis的SQL日志审计
在application.yml中配置:
yaml复制mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
通过日志分析可发现:
- 使用
#{}生成的SQL会显示为?占位符 - 使用
${}会直接显示拼接后的完整SQL
3.2 自定义类型处理器防御特殊字符
java复制@MappedTypes(String.class)
public class SafeStringTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
String parameter, JdbcType jdbcType) {
// 对特殊字符进行转义处理
String safeValue = StringEscapeUtils.escapeSql(parameter);
ps.setString(i, safeValue);
}
//...其他方法实现
}
在配置中注册:
java复制@Bean
public ConfigurationCustomizer mybatisConfigCustomizer() {
return configuration -> {
configuration.getTypeHandlerRegistry()
.register(String.class, new SafeStringTypeHandler());
};
}
3.3 集成SQL防火墙插件
java复制@Intercepts(@Signature(type= StatementHandler.class,
method="prepare", args={Connection.class, Integer.class}))
public class SqlInjectionInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
// 检测SQL中的危险模式
if (boundSql.getSql().matches("(?i).*\\$\\{[^}]+\\}.*")) {
throw new IllegalArgumentException("禁止使用${}动态拼接SQL");
}
return invocation.proceed();
}
}
4. 实战演练:从漏洞挖掘到安全修复
4.1 搭建靶场环境
使用Spring Boot + Mybatis搭建测试项目:
java复制@Entity
public class User {
@Id private Long id;
private String username;
private String password;
// getters/setters
}
public interface UserMapper {
@Select("SELECT * FROM users WHERE username='${username}' AND password='${password}'")
User login(@Param("username") String u, @Param("password") String p);
}
4.2 模拟攻击测试
尝试注入攻击:
java复制// 正常登录
mapper.login("admin", "123456");
// SQL注入攻击
mapper.login("admin' -- ", "任意密码");
// 生成的SQL: SELECT * FROM users WHERE username='admin' -- ' AND password='任意密码'
4.3 逐步修复过程
第一步:改用#{}预处理
java复制@Select("SELECT * FROM users WHERE username=#{username} AND password=#{password}")
第二步:增加密码加密比对
java复制@Select("SELECT * FROM users WHERE username=#{username}")
User getByUsername(@Param("username") String username);
// Service层处理
public User login(String username, String password) {
User user = mapper.getByUsername(username);
if (user != null && encryptor.matches(password, user.getPassword())) {
return user;
}
return null;
}
第三步:添加审计日志
java复制@Around("execution(* com.example.mapper.*.*(..))")
public Object auditSql(ProceedingJoinPoint pjp) throws Throwable {
Object[] args = pjp.getArgs();
// 记录参数值、调用者IP等信息
return pjp.proceed();
}
5. 进阶防护:Mybatis-Plus的特殊考量
5.1 QueryWrapper的安全使用
危险写法:
java复制QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.apply("username = '" + username + "'");
安全方案:
java复制// 使用预编译方式
wrapper.eq("username", username);
// 必须使用字符串条件时
wrapper.apply("username = {0}", username);
5.2 自定义SQL片段的安全组合
java复制public class SafeSqlUtils {
public static String inSafe(String column, Collection<?> values) {
return values.stream()
.map(v -> "#{" + v + "}")
.collect(Collectors.joining(",", column + " IN (", ")"));
}
}
// 使用示例
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.apply(SafeSqlUtils.inSafe("dept_id", deptIds));
5.3 分页插件的安全配置
在MybatisPlusConfig中:
java复制@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 安全的分页插件配置
PaginationInnerInterceptor pagination = new PaginationInnerInterceptor();
pagination.setMaxLimit(1000L); // 防止分页DOS攻击
interceptor.addInnerInterceptor(pagination);
return interceptor;
}
6. 企业级安全开发规范建议
6.1 代码审查Checklist
- [ ] 禁止在注解SQL中使用
${} - [ ] XML映射文件中动态SQL必须使用
<foreach>处理集合 - [ ] 所有表名/列名动态化必须经过白名单校验
- [ ] 模糊查询必须使用
CONCAT或参数预处理 - [ ] 分页参数必须设置合理上下限
6.2 自动化安全检测方案
方案一:集成OWASP Dependency-Check
xml复制<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>7.1.1</version>
<executions>
<execution>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
方案二:使用Git Hooks预检查
bash复制#!/bin/sh
# pre-commit hook
if git diff --cached | grep -E '\$\{[^}]+\}'; then
echo "发现SQL拼接风险!"
exit 1
fi
6.3 安全培训重点内容
-
基础认知:
- SQL注入的原理与危害
- Mybatis两种参数处理的本质区别
-
安全编码:
- 动态SQL的正确编写方式
- 危险API的黑名单
-
应急响应:
- 注入漏洞的识别方法
- 漏洞修复的标准流程
我在实际项目审查中发现,即使是经验丰富的开发者,在赶进度时也容易忽略SQL注入防护。建议团队建立代码模板库,把安全的CRUD操作封装成标准模板,新成员可以直接引用这些经过安全验证的代码片段,从源头减少漏洞产生。
