SQL 注入防护
SQL 注入是数据库应用中最危险的安全漏洞之一。攻击者通过构造恶意输入,改变 SQL 语句的逻辑结构,从而窃取数据、篡改记录甚至删除整个表。MyBatis 提供了 #{} 和 ${} 两种参数绑定方式,理解其本质差异是防范 SQL 注入的核心。
#{} 与 ${} 的本质差异
#{}(预编译参数绑定)
#{} 使用 PreparedStatement 的占位符机制,参数值通过 JDBC 协议单独传输,不会改变 SQL 语句的结构:
XML
<!-- 安全写法 -->
<select id="selectUserById" resultType="User">
SELECT id, username, email FROM users WHERE id = #{id}
</select>
实际执行的 SQL:
Java
// MyBatis 内部实现
String sql = "SELECT id, username, email FROM users WHERE id = ?";
PreparedStatement ps = connection.prepareStatement(sql);
ps.setObject(1, id); // 参数作为值传入,不是 SQL 的一部分
ResultSet rs = ps.executeQuery();
| 特性 | 说明 |
|---|---|
| 执行方式 | PreparedStatement 预编译 |
| SQL 结构 | 固定不变,参数只作为值 |
| 防注入 | 是,参数不会解析为 SQL 关键字 |
| 性能 | 高,执行计划可缓存复用 |
| 适用场景 | 值参数(WHERE 条件、INSERT 值等) |
${}(字符串直接拼接)
${} 将参数值直接拼接到 SQL 字符串中,在 SQL 预编译之前就完成替换:
XML
<!-- 危险写法! -->
<select id="selectUserByOrder" resultType="User">
SELECT id, username FROM users ORDER BY ${column} ${direction}
</select>
实际执行的 SQL(攻击者输入 column = "1; DROP TABLE users --"):
SQL
SELECT id, username FROM users ORDER BY 1; DROP TABLE users -- DESC
| 特性 | 说明 |
|---|---|
| 执行方式 | SQL 字符串直接拼接 |
| SQL 结构 | 可变,参数成为 SQL 的一部分 |
| 防注入 | 否,参数会解析为 SQL 关键字 |
| 性能 | 低,每次执行计划不同 |
| 适用场景 | 动态表名、列名、排序字段等非值参数 |
对比演示
XML
输入: id = "1 OR 1=1"
#{id} 执行: SELECT * FROM users WHERE id = '1 OR 1=1'
-- 查找 id 为 "1 OR 1=1" 的记录(不存在),安全
${id} 执行: SELECT * FROM users WHERE id = 1 OR 1=1
-- 条件变为 1=1,返回所有记录,注入成功!
常见注入场景与防护
场景 1:WHERE 条件参数(必须用 #{})
XML
<!-- 正确写法 -->
<select id="selectUsersByStatus" resultType="User">
SELECT id, username, email
FROM users
WHERE status = #{status}
AND create_time >= #{startTime}
</select>
<!-- 错误写法!注入漏洞 -->
<select id="selectUsersByStatusUnsafe" resultType="User">
SELECT id, username, email
FROM users
WHERE status = ${status}
</select>
场景 2:IN 子句参数
XML
<!-- 正确写法:使用 foreach + #{} -->
<select id="selectUsersByIds" resultType="User">
SELECT id, username, email
FROM users
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
<!-- 生成的安全 SQL -->
SELECT id, username, email FROM users WHERE id IN (?, ?, ?)
<!-- 错误写法!使用 ${} 拼接列表 -->
<select id="selectUsersByIdsUnsafe" resultType="User">
SELECT id, username, email FROM users WHERE id IN (${ids})
</select>
<!-- 如果 ids = "1, 2); DROP TABLE users; --" -->
<!-- SELECT id, username FROM users WHERE id IN (1, 2); DROP TABLE users; --) -->
场景 3:LIKE 模糊查询
Java
<!-- 正确写法 1:在参数值中添加 % -->
<select id="searchUsers" resultType="User">
SELECT id, username, email
FROM users
WHERE username LIKE #{keyword}
</select>
<!-- Java 层传入: "%admin%" -->
<!-- 正确写法 2:使用 CONCAT 函数 -->
<select id="searchUsersConcat" resultType="User">
SELECT id, username, email
FROM users
WHERE username LIKE CONCAT('%', #{keyword}, '%')
</select>
<!-- 错误写法! -->
<select id="searchUsersUnsafe" resultType="User">
SELECT id, username, email
FROM users
WHERE username LIKE '%${keyword}%'
</select>
<!-- 输入 keyword = "'; DROP TABLE users; --" -->
<!-- WHERE username LIKE '%'; DROP TABLE users; --%' -->
场景 4:动态排序(唯一可使用 ${} 的场景)
当需要动态指定排序列时,必须使用白名单校验:
XML
// 白名单校验
public class OrderByValidator {
private static final Set<String> ALLOWED_COLUMNS = Set.of(
"id", "username", "email", "create_time", "update_time"
);
private static final Set<String> ALLOWED_DIRECTIONS = Set.of("ASC", "DESC");
public static String validateColumn(String column) {
if (!ALLOWED_COLUMNS.contains(column.toLowerCase())) {
throw new IllegalArgumentException("Invalid order column: " + column);
}
return column;
}
public static String validateDirection(String direction) {
if (!ALLOWED_DIRECTIONS.contains(direction.toUpperCase())) {
throw new IllegalArgumentException("Invalid order direction: " + direction);
}
return direction.toUpperCase();
}
}
Java
<!-- 使用经过校验的参数 -->
<select id="selectUsersOrdered" resultType="User">
SELECT id, username, email
FROM users
ORDER BY ${column} ${direction}
</select>
Java
// Service 层调用
public List<User> getUsersOrdered(String column, String direction) {
// 先校验白名单
String safeColumn = OrderByValidator.validateColumn(column);
String safeDirection = OrderByValidator.validateDirection(direction);
Map<String, Object> params = new HashMap<>();
params.put("column", safeColumn);
params.put("direction", safeDirection);
return userMapper.selectUsersOrdered(params);
}
场景 5:动态表名
动态表名同样只能使用 ${},必须通过白名单严格控制:
XML
// 表名白校验
public class TableNameValidator {
private static final Set<String> ALLOWED_TABLES = Set.of(
"users", "orders", "products", "categories"
);
public static String validate(String tableName) {
if (!ALLOWED_TABLES.contains(tableName.toLowerCase())) {
throw new IllegalArgumentException("Invalid table name: " + tableName);
}
return tableName;
}
}
Java
<!-- 分表场景 -->
<select id="selectFromTable" resultType="User">
SELECT * FROM ${tableName} WHERE id = #{id}
</select>
SQL 注入审计拦截器
自动检测不安全 SQL 的插件
开发一个 MyBatis 拦截器,自动检测 XML 中使用的 ${} 拼接:
text
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
public class SqlInjectionAuditInterceptor implements Interceptor {
private static final Logger log = LoggerFactory.getLogger(SqlInjectionAuditInterceptor.class);
private static final Pattern DOLLAR_BRACE_PATTERN = Pattern.compile("\\$\\{[^}]+\\}");
// 允许的 ${} 使用场景(白名单关键词)
private static final Set<String> ALLOWED_PATTERNS = Set.of(
"ORDER BY", "GROUP BY", "LIMIT", "OFFSET"
);
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
BoundSql boundSql = handler.getBoundSql();
String sql = boundSql.getSql();
auditSql(sql, boundSql);
return invocation.proceed();
}
private void auditSql(String sql, BoundSql boundSql) {
Matcher matcher = DOLLAR_BRACE_PATTERN.matcher(sql);
while (matcher.find()) {
String dollarUsage = matcher.group();
String context = getContextAroundMatch(sql, matcher.start());
boolean isAllowed = ALLOWED_PATTERNS.stream()
.anyMatch(context::contains);
if (!isAllowed) {
log.warn("[SQL INJECTION AUDIT] Suspicious ${{}} usage detected: {} in SQL: {}",
dollarUsage, sql.substring(0, Math.min(200, sql.length())));
}
}
}
private String getContextAroundMatch(String sql, int startIndex) {
int contextStart = Math.max(0, startIndex - 30);
return sql.substring(contextStart, startIndex).toUpperCase();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 可从配置读取允许的模式
}
}
安全检查清单
| 检查项 | 规则 | 工具/方式 |
|---|---|---|
XML 中 ${} 使用 | 必须有白名单校验 | 代码审查 + 拦截器 |
| WHERE/INSERT/UPDATE 值参数 | 必须使用 #{} | 代码审查 |
| 动态表名/列名 | ${} + 白名单校验 | 代码审查 |
| 排序/分组 | ${} + 枚举/白名单 | 代码审查 |
| LIKE 查询 | #{} + CONCAT 或 Java 层加 % | 代码审查 |
| IN 子句 | <foreach> + #{} | 代码审查 |
| 用户输入过滤 | 参数化查询,不拼接 | PreparedStatement |
各数据库注入防护差异
| 数据库 | LIKE 语法 | 特殊注意 |
|---|---|---|
| MySQL | LIKE CONCAT('%', #{kw}, '%') | 反引号包裹列名 |
| PostgreSQL | `LIKE '%' | |
| Oracle | `LIKE '%' | |
| SQL Server | LIKE '%' + #{kw} + '%' | 方括号标识符 |
要点总结
#{}使用 PreparedStatement 预编译,参数作为值传输,能防御 SQL 注入;${}直接字符串拼接,参数成为 SQL 的一部分,无法防御注入- WHERE 条件、INSERT 值、UPDATE 值等所有值参数必须使用
#{} - IN 子句使用
<foreach>标签配合#{},禁止使用${}拼接列表 - LIKE 查询使用
#{}并在 Java 层添加%,或使用CONCAT('%', #{keyword}, '%') - 动态表名、列名、排序字段等只能使用
${}的场景,必须实施白名单校验,禁止直接使用用户输入 - 可开发审计拦截器自动检测 XML 中不安全的
${}使用,在开发和测试阶段发现潜在漏洞 - 不同数据库的 LIKE 拼接语法有差异,但核心原则一致:值参数永远使用预编译绑定
存放路径:D:\git2\jwdev\articles\MYBATIS\专家\生产环境最佳实践\SQL 注入防护.md
📝 发现内容有误?点击此处直接编辑