全部学科
NodeJS全栈
nodejs
Python全栈
python
小程序首页
📅 2026-05-20 8 分钟 ✍️ juanwangdev

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 语法特殊注意
MySQLLIKE CONCAT('%', #{kw}, '%')反引号包裹列名
PostgreSQL`LIKE '%'
Oracle`LIKE '%'
SQL ServerLIKE '%' + #{kw} + '%'方括号标识符

要点总结

  • #{} 使用 PreparedStatement 预编译,参数作为值传输,能防御 SQL 注入${} 直接字符串拼接,参数成为 SQL 的一部分,无法防御注入
  • WHERE 条件、INSERT 值、UPDATE 值等所有值参数必须使用 #{}
  • IN 子句使用 <foreach> 标签配合 #{},禁止使用 ${} 拼接列表
  • LIKE 查询使用 #{} 并在 Java 层添加 %,或使用 CONCAT('%', #{keyword}, '%')
  • 动态表名、列名、排序字段等只能使用 ${} 的场景,必须实施白名单校验,禁止直接使用用户输入
  • 可开发审计拦截器自动检测 XML 中不安全的 ${} 使用,在开发和测试阶段发现潜在漏洞
  • 不同数据库的 LIKE 拼接语法有差异,但核心原则一致:值参数永远使用预编译绑定

存放路径:D:\git2\jwdev\articles\MYBATIS\专家\生产环境最佳实践\SQL 注入防护.md

📝 发现内容有误?点击此处直接编辑

← 上一篇 租户隔离策略
下一篇 → 异常处理与重试
想查看更多题目和详细解析?
小程序提供完整的题库、模拟考试和详细解析
马上就来

长按或扫描二维码,立即体验

扫码体验小程序
马上就来
使用微信扫描二维码
立即体验完整题库