SQL 注入防护进阶:MyBatis #{} 和 ${} 的区别——不止是预编译

引言

去年做代码安全审计,SpotBugs 报了一个 SQL_INJECTION 告警,定位过去一看:

<select id="listOrders" resultType="Order">
    SELECT * FROM orders WHERE status = #{status}
    ORDER BY ${sortColumn} ${sortOrder}
</select>

开发同事很委屈:"WHERE 条件我全部用的 #{} 预编译啊,注入不了!"我问他:如果 sortColumn 传入 (CASE WHEN (SELECT 1=1) THEN id ELSE status END),这条 SQL 会发生什么?他愣住了。

测试一下:请求带上 sortColumn=(CASE WHEN (substr(database(),1,1)='p') THEN id ELSE create_time END),接口返回的排序结果不一样——攻击者可以通过排序结果的变化,一个字符一个字符把数据库名、表名、数据全部拖走。这就是经典的 ORDER BY 注入,也是绝大多数 MyBatis 项目的知识盲区。

#{}${} 的区别,面试八股文都会背:"一个预编译防注入,一个字符串拼接危险"。但背过的人里,十个有九个说不清楚:**为什么预编译能防住?ORDER BY 为什么预编译不了?不能预编译的场景怎么安全处理?**这篇文章把这些一次讲透,最后附 OWASP 防御检查表和常见绕 WAF 手法。


一、从原理讲起:预编译为什么能防注入

1.1 SQL 注入的本质

先说清本质:SQL 注入是"数据"被当成了"代码"执行

// 用户输入的 userId = "1 OR 1=1"
String sql = "SELECT * FROM users WHERE id = " + userId;
// 拼出来的 SQL:
// SELECT * FROM users WHERE id = 1 OR 1=1
//                           ^^^^^^^^ 数据变成了 SQL 语法的一部分

拼接字符串时,数据库拿到的是一条"完整的、语法结构已确定"的 SQL 文本,它无法区分"1 OR 1=1"是用户的数据还是开发者的意图。注入的根源是代码和数据没有边界

1.2 PreparedStatement 预编译:代码和数据的边界

#{} 在 MyBatis 里的真实行为:SQL 模板先发给数据库预编译,参数值后传,二者物理分离:

① MyBatis 生成 SQL 模板:
   SELECT * FROM users WHERE id = ?
   (发送给 MySQL 预编译,此时 SQL 语法树已固定)

② 参数值单独传输:
   参数 = "1 OR 1=1"
   (作为纯数据绑定到 ? 占位符)

③ 数据库执行:
   在固定的语法树里查找 id = "1 OR 1=1" 这个字符串值
   → 找不到 id 等于这个字符串的记录
   → 返回空结果(而不是全表数据!)

关键在 ②③:"1 OR 1=1" 整个字符串被当作一个值,与 id 字段做相等比较,永远不会被解析成 SQL 语法。这就是预编译防注入的原理——语法树先固化,数据后进入,数据永远成不了语法

1.3 三个值得深究的细节

细节一:#{} 会根据参数类型自动加引号

<!-- userId 传 "1 OR 1=1" -->
WHERE id = #{userId}
<!-- 实际执行:WHERE id = '1 OR 1=1' (整串是带引号的字符串值) -->

字符串参数自动加引号并做转义,数字参数(Long/Integer)不匹配类型直接报错——类型系统本身就是一道防线

细节二:Like 查询 #{} 也可能出问题

<!-- ❌ 不是注入问题,而是功能问题:这样写匹配不到数据 -->
WHERE name LIKE '%#{keyword}%'
<!-- MyBatis 会生成 WHERE name LIKE '%'?'%' → 语法错误 -->

<!-- ✅ 正确做法:参数拼接放在 Java 层 -->
WHERE name LIKE #{keyword}
// Java: keyword = "%" + input + "%";

注意:Java 层拼 %_ 通配符不是注入漏洞(预编译保护了数据边界),但用户输入的 % 会成为通配符导致全表扫描——这是性能问题,防不住的话用户输入一个 % 就能把你查挂。

细节三:预编译在连接池下有失效的场景

MySQL 的预编译有两种模式:

模式原理防注入
服务端预编译useServerPrepStmts=true真正的两阶段:prepare 发模板、execute 发参数✅ 模板和数据分离
客户端预编译(默认)JDBC 驱动在客户端做转义替换,最终仍发送完整 SQL✅ 靠转义防注入

默认的客户端预编译靠驱动转义,绝大多数场景安全。但多语句执行开启时(allowMultiQueries=true)是高危配置——即便有转义,也放大了其他漏洞的杀伤力。生产连接串建议:

jdbc:mysql://...?useServerPrepStmts=true&cachePrepStmts=true
(不要开 allowMultiQueries,除非有明确的批量需求且输入可控)

二、${} 为什么危险,又为什么不可替代

2.1 ${} 的真实行为

${}纯文本替换——MyBatis 在生成 SQL 阶段直接把参数值替换进模板,不经过预编译、不加引号、不转义:

ORDER BY ${sortColumn}
<!-- sortColumn = "id" → ORDER BY id (正常) -->
<!-- sortColumn = "id; DROP TABLE users" → 直接拼进 SQL 语法(注入!)-->

看源码更直观,MyBatis 处理 ${} 用的就是 TextSqlNode 的字符串替换,处理 #{} 用的才是 ParameterMapping 的占位符绑定。${} 阶段的 SQL 已经是最终文本,数据库收到的就是拼好的语法

2.2 ${} 的合法使用场景:SQL 结构的组成部分

${} 为什么没被砍掉?因为有些位置根本不能是"值",必须是 SQL 语法的组成部分

场景为什么不能用 #{}只能 ${} + 白名单
ORDER BY ${column}预编译后变成 ORDER BY 'id',按常量字符串排序,等于没排序
GROUP BY ${column}同上,按常量分组失去意义
表名 FROM ${tableName}表名不能加引号,FROM 'user' 语法错误✅ 分库分表场景
列名 SELECT ${columns}列名加引号查的是常量✅ 动态报表列
关键字 ORDER BY id ${ascDesc}ASC/DESC 不能加引号

一句话:#{} 处理"数据",${} 处理"语法"。凡是语义上是数据的位置用 #{},凡是必须是语法结构的位置才用 ${}——但必须叠加白名单校验。

2.3 ORDER BY 注入:最常被忽视的洞

ORDER BY 注入虽然不能直接 UNION SELECT 拖库(ORDER BY 位置注入受限),但攻击者有成熟的利用手法:

手法一:布尔盲注(基于 CASE WHEN)

-- 攻击者构造的 sortColumn:
(CASE WHEN (SELECT substr(password,1,1) FROM users WHERE username='admin')='a'
      THEN id ELSE create_time END)

-- 密码首字符是 'a' → 按 id 排序(页面呈现状态 A)
-- 密码首字符不是 'a' → 按 create_time 排序(页面呈现状态 B)
-- 攻击者遍历 26 个字母 × N 个字符位,把 admin 的密码逐字符猜出来

手法二:报错注入(基于 extractvalue/updatexml)

-- sortColumn 传入:
(SELECT extractvalue(1, concat(0x7e, (SELECT version()))))
-- MySQL 报错信息里带出数据库版本等敏感信息,报错信息若回显给前端即泄露

手法三:时间盲注(基于 SLEEP)

-- sortColumn 传入:
IF(SUBSTR(database(),1,1)='p', SLEEP(3), 0)
-- 数据库名首字符是 'p' → 响应慢 3 秒;不是 → 立即返回
-- 响应时间就是攻击者的"布尔信号"

防御判断:如果你的接口把排序字段直接透传给 ${},且响应时间/排序结果/报错信息有可观察差异——就是可注入的。下面给完整的防护方案。


三、ORDER BY / GROUP BY 场景的安全实现

3.1 方案一:白名单校验(OWASP 首选)

原则:排序字段不该由前端随意指定,只允许预定义的合法值

/**
 * 排序字段白名单:所有允许排序的列在这里显式枚举
 * 前端传来的任何值,不在白名单内一律回退到默认值
 */
public enum OrderByColumn {

    CREATE_TIME("create_time"),
    ORDER_AMOUNT("order_amount"),
    ORDER_STATUS("order_status"),
    USER_NAME("user_name");

    private final String columnName;

    OrderByColumn(String columnName) {
        this.columnName = columnName;
    }

    /**
     * 解析前端传入的排序字段
     * 非法值返回默认排序(不抛异常,防止攻击者用报错探测)
     */
    public static OrderByColumn safeParse(String input) {
        for (OrderByColumn col : values()) {
            if (col.name().equalsIgnoreCase(input) || col.columnName.equalsIgnoreCase(input)) {
                return col;
            }
        }
        return CREATE_TIME;   // 默认值,静默回退
    }

    public String getColumnName() {
        return columnName;
    }
}
/**
 * 排序方向白名单:只有 ASC / DESC 两个合法值
 */
public static String safeParseDirection(String input) {
    return "desc".equalsIgnoreCase(input) ? "DESC" : "ASC";
}

Service 层组合:

public List<OrderVO> listOrders(OrderQuery query) {
    // 白名单解析:枚举里的值是我们自己定义的常量,不是用户输入
    OrderByColumn column = OrderByColumn.safeParse(query.getSortColumn());
    String direction = safeParseDirection(query.getSortOrder());

    // 传给 SQL 的是枚举解析出的常量,用户输入已被"消化"掉
    return orderMapper.listOrders(query.getStatus(),
            column.getColumnName(), direction);
}
<select id="listOrders" resultType="OrderVO">
    SELECT * FROM orders WHERE status = #{status}
    ORDER BY ${sortColumn} ${sortDirection}
    <!-- 这里的 ${} 是安全的:值来自枚举常量,用户输入根本到不了这里 -->
</select>

安全的关键不是 ${} 前后做了校验,而是"到达 ${} 的值已经是白名单产物,用户输入从未直接进入 SQL"

3.2 方案二:排序字段映射表(前端传别名)

更彻底的做法:前端只传业务别名,后端维护"别名 → 物理列名"的映射,物理列名完全不暴露:

/**
 * 别名映射:前端只见 business 别名,物理列名是服务端实现细节
 * 好处:① 防注入 ② 隐藏表结构 ③ 改物理列名不影响前端
 */
private static final Map<String, String> SORT_ALIAS = Map.of(
        "time",      "create_time",
        "amount",    "order_amount",
        "status",    "order_status",
        "user",      "user_name"
);

public String resolveSortColumn(String alias) {
    // Map.of 创建的映射表本身就是白名单,get 不到就是非法值
    return SORT_ALIAS.getOrDefault(
            alias == null ? "" : alias.toLowerCase(), "create_time");
}

3.3 方案三:MyBatis 拦截器统一兜底

多个 Mapper 都有动态排序时,逐个校验容易漏。用拦截器在 SQL 执行前统一做"列名合法性"检查:

/**
 * SQL 执行前的动态排序兜底拦截器
 * 从 SQL 里解析出 ORDER BY 后的标识符,逐个检查是否为物理表的真实列名
 */
@Intercepts(@Signature(type = StatementHandler.class, method = "prepare",
        args = {Connection.class, Integer.class}))
@Component
public class OrderByGuardInterceptor implements Interceptor {

    private static final Pattern ORDER_BY_PATTERN =
            Pattern.compile("ORDER\\s+BY\\s+([\\w,\\s.]+)$", Pattern.CASE_INSENSITIVE);

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        StatementHandler handler = (StatementHandler) invocation.getTarget();
        BoundSql boundSql = handler.getBoundSql();
        String sql = boundSql.getSql();

        Matcher matcher = ORDER_BY_PATTERN.matcher(sql.trim());
        if (matcher.find()) {
            // 逐个检查 ORDER BY 后的列名是否存在于目标表的物理列
            String[] columns = matcher.group(1).split(",");
            for (String col : columns) {
                String column = col.trim().split("\\s+")[0]   // 去掉 ASC/DESC
                        .replaceAll(".*\\.", "");             // 去掉表前缀
                if (!isValidColumn(sql, column)) {
                    throw new IllegalArgumentException("非法排序字段: " + column);
                }
            }
        }
        return invocation.proceed();
    }

    /**
     * 列名校验:查 information_schema 确认列存在(带本地缓存)
     * 能过这一关的,必须是数据库真实存在的物理列名
     */
    private boolean isValidColumn(String sql, String column) {
        if (!COLUMN_PATTERN.matcher(column).matches()) {   // ^[a-zA-Z_][a-zA-Z0-9_]*$
            return false;
        }
        // 从缓存拿目标表的物理列集合(首次查询 information_schema 后缓存)
        Set<String> physicalColumns = columnCache.resolveTableColumns(sql);
        return physicalColumns.isEmpty() || physicalColumns.contains(column.toLowerCase());
    }
}

分层防御的定位:白名单/映射表是主防线(业务正确性优先),拦截器是兜底线(防的是"某个新 Mapper 忘了走白名单")。两层都在,才是完整方案。

3.4 表名/列名动态场景:同样的思路

分库分表、动态报表的表名拼接,同样用白名单:

// ❌ 危险:表名直接透传
FROM ${tableName}

// ✅ 安全:表名只允许"分片编号"这种受控输入
public String resolveTableName(int shardIndex) {
    if (shardIndex < 0 || shardIndex >= SHARD_COUNT) {
        throw new IllegalArgumentException("非法分片编号");
    }
    return "orders_" + shardIndex;   // 数字是 int 类型,拼出来只能是合法表名
}

// ✅ 动态报表列:白名单映射
private static final Set<String> ALLOWED_COLUMNS = Set.of(
        "order_id", "user_id", "amount", "status", "create_time");

public String buildSelectColumns(List<String> requested) {
    // 过滤 + join,非法列直接丢弃(或抛异常,按业务定)
    return requested.stream()
            .map(c -> c.trim().toLowerCase())
            .filter(ALLOWED_COLUMNS::contains)
            .collect(Collectors.joining(", "));
}

四、OWASP 的 SQL 注入防御检查表

4.1 防御清单(按优先级)

OWASP SQL Injection Prevention Cheat Sheet 的核心条目,按落地优先级整理:

#防御措施说明落地方式
1预编译参数化查询第一道也是最重要的防线MyBatis #{}、JDBC PreparedStatement、JPA 参数绑定
2存储过程参数化内部实现也必须是参数化的存储 #{} 调用,存储过程内部不用拼接
3白名单输入校验预编译覆盖不了的语法位(ORDER BY/表名/列名)枚举/映射表/正则 ^[a-zA-Z_][a-zA-Z0-9_]*$
4最小权限即使被注入也减小杀伤应用账号只授 SELECT/INSERT/UPDATE/DELETE,禁 DDL/DROP、禁 FILE 权限
5转义(最后手段)不推荐作为主防线ESAPI 编码器;转义有方言差异,容易漏
6错误信息不回显切断报错注入的信息通道全局异常处理器,生产环境返回通用错误码
7ORM 不等于免疫动态排序/原生 SQL/拼接 HQL 仍会中招ORM 也要走本清单 1-4
8纵深防御WAF/审计/代码扫描WAF 规则 + dependency-check/SpotBugs 扫描 + SQL 审计日志

4.2 最小权限的实际配置

被忽视但性价比极高的一条。即使被注入成功,最小权限能把"拖库"变成"查不出敏感表":

-- ❌ 危险:应用账号用 root
-- ✅ 安全:业务账号最小权限
CREATE USER 'order_app'@'10.0.%' IDENTIFIED BY '***';

GRANT SELECT, INSERT, UPDATE, DELETE ON order_db.orders TO 'order_app'@'10.0.%';
GRANT SELECT, INSERT, UPDATE, DELETE ON order_db.order_items TO 'order_app'@'10.0.%';
-- 不授予:users 表的 password 列(用视图限制列)
-- 不授予:information_schema 访问(防注入探测表结构)
-- 不授予:FILE 权限(防 INTO OUTFILE 写文件)
-- 不授予:DROP/ALTER/CREATE(防 DDL 破坏)

配合列级视图进一步收口:

-- 应用账号通过视图访问,视图里不含敏感列
CREATE VIEW v_user_public AS
SELECT id, username, nickname, created_at FROM users;
-- order_app 只能查视图,password/hash 列物理上不可达
GRANT SELECT ON order_db.v_user_public TO 'order_app'@'10.0.%';

4.3 错误信息处理

/**
 * 全局异常处理:生产环境绝不回显 SQL 异常细节
 * 报错注入依赖"错误信息里带数据",切断回显就切断了这条通道
 */
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(DataAccessException.class)
    public ResponseEntity<?> handleDbError(DataAccessException e, HttpServletRequest request) {
        // 完整堆栈记日志(含 traceId 便于排查)
        log.error("DB error, uri={}", request.getRequestURI(), e);
        // 对外只返回通用错误——不含 SQL 片段、表名、异常类型
        return ResponseEntity.status(500)
                .body(Map.of("code", "SYSTEM_ERROR", "message", "系统繁忙,请稍后重试"));
    }
}

五、常见绕 WAF 手法:知己知彼

WAF 是纵深防御的一层,不是银弹。了解绕过手法的目的有两个:① 理解为什么"代码层参数化"才是根本防线;② 检验自家 WAF 规则的有效性

5.1 常见绕过手法一览

手法原理示例
注释混淆在 SQL 关键词里插注释打断匹配UN/**/ION SE/**/LECTsel%23ect(MySQL # 注释 URL 编码)
编码变形URL 编码/双重编码/Unicode 编码%55NION(U 的 URL 编码)、双重编码 %2527
大小写混合SQL 不区分大小写,部分 WAF 规则区分uNiOn SeLeCt
空白替代%09(Tab)、%0a(换行)、%0d 代替空格UNION%09SELECT
内联注释MySQL 特有:/*!50000 UNION*/ 版本注释内执行/*!12345union*/ /*!12345select*/
等价函数替换换成 WAF 规则没覆盖的同义函数extractvalueupdatexmlsubstrmid/left
分块传输HTTP chunked 把 payload 切片,WAF 拼不全POST 分块编码
参数污染重复参数让 WAF 和后端取值不一致?id=1&id=UNION SELECT(WAF 检查第一个,后端取最后一个)
超长参数超 WAF 检测长度限制的 payload 被跳过万字符填充
JSON/XML 包裹payload 藏在 WAF 不解析的内容类型里Content-Type: application/json 深层字段注入

5.2 一个具体例子:从拦截到绕过

以最基础的 UNION SELECT 检测为例,攻击者的演化路径:

payload v1:  1 UNION SELECT username,password FROM users
WAF:         命中 "UNION SELECT" 规则 → 拦截

payload v2:  1 uNiOn SeLeCt ...        (大小写混合)
WAF:         规则加了 ignore-case → 拦截

payload v3:  1 UN/**/ION SE/**/LECT ... (注释混淆)
WAF:         规则加注释剥离 → 拦截

payload v4:  1 /*!50000UNION*/ /*!50000SELECT*/ ... (MySQL 版本注释)
WAF:         特征库更新滞后 → 可能绕过

payload v5:  无 UNION,纯 CASE WHEN 盲注(响应差异信道)
WAF:         无明显注入特征 → 基本拦不住

结论一目了然:盲注类攻击几乎没有特征,WAF 拦不住。这就是为什么 OWASP 把预编译 + 白名单放在最前面——WAF 是盾牌上的第四层甲,代码层参数化才是身体本身

5.3 WAF 的正确使用姿势

姿势说明
定位为纵深防御一层假设它会被绕过,代码层防线必须独立成立
拦截模式 + 监控模式并行新规则先监控观察误杀,再切拦截
结合白名单能力管理后台等固定路径直接限制参数格式,比通用正则更有效
日志联动WAF 拦截记录接入告警体系,同 IP 高频拦截触发封禁
定期红蓝对抗用 5.1 的手法做穿透测试,检验规则有效性

六、常见问题

6.1 #{} 一定安全吗?

参数化查询本身安全,但两个场景会失效:① 动态 SQL 的语法位(ORDER BY/表名/列名)用了 ${},这是本文的核心;② Java 层先把用户输入拼进 SQL 模板再交给 MyBatis——比如 @Select("... WHERE id = " + userId) 这种注解拼接,等于绕过了 #{} 机制。CI 里用 SpotBugs 的 SQL_INJECTION 规则扫这类代码(参考我们安全工具清单那篇的配置)。

6.2 开了预编译,为什么还要做输入校验?

输入校验和参数化解决的是不同层面的问题:参数化防注入,校验防"非法数据"。手机号字段校验 ^1\d{10}$,不只是安全,更是数据质量。另外校验能在入口层拒绝明显恶意的输入,降低下游(日志、缓存、序列化)的处理风险。两者是互补关系,不是替代关系。

6.3 MyBatis-Plus 的 QueryWrapper 会注入吗?

QueryWrapper 的 eq/like/in 方法都是参数化绑定(#{} 机制),是安全的。但注意三个边界:① last("LIMIT " + userLimit) 是拼接,危险;② orderByAsc/Desc 的字段名会拼进 SQL 语法位,必须用白名单——MyBatis-Plus 提供了 LambdaQueryWrapper(编译期方法引用,天然白名单);③ apply() 方法里的占位符要传参数而不是拼接。

// ❌ 字段名透传
wrapper.orderByAsc(request.getColumn());

// ✅ Lambda 方法引用:编译期确定,天然白名单
wrapper.orderByAsc(Order::getCreateTime);

6.4 存储过程怎么防注入?

存储过程本身如果内部拼接字符串,注入照样发生——参数化的存储过程才有效。调用侧用 #{} 传参(JDBC 用 CallableStatement 的参数绑定,不用字符串拼接调用)。审计存储过程时重点看内部有没有 EXECUTE CONCAT(...)sp_executesql 的拼接调用。

6.5 MyBatis 的 $ 会被 SonarQube/SpotBugs 扫出来吗?

SpotBugs + Find Security Bugs 能扫出 Java 层的 SQL 拼接,但 MyBatis XML 里的 ${} 是字符串替换,静态工具容易漏。补充手段:① 自定义 CI 规则扫描 Mapper XML 的 ${} 使用,输出清单人工确认(合法场景:ORDER BY/表名/列名 + 有白名单保障);② 运行时用 3.3 的拦截器兜底;③ 上线前用 sqlmap 之类的工具对接口做注入测试。

6.6 已经线上了,怎么快速排查存量 ${} 风险?

三步走:① 全量扫描 Mapper XML,列出所有 ${} 使用点(grep 即可,量大就写脚本);② 按"数据位还是语法位"分类——数据位(WHERE/VALUES 里)立即改 #{},语法位(ORDER BY/表名)补白名单;③ 给没有白名单的语法位加 3.3 的拦截器兜底。我们的经验:数据位占 90% 以上,一周内能清完;语法位补白名单视接口数量,两周到一个月。


七、总结

防御体系速查卡

┌────────────┬────────────────────────────────────────────────┐
│ 层次        │ 措施                                            │
├────────────┼────────────────────────────────────────────────┤
│ 代码层(根本) │ #{} 参数化;${} 仅限语法位 + 白名单/映射表       │
│ 应用层(兜底) │ MyBatis 拦截器校验动态排序列名                   │
│ 权限层(减伤) │ 最小权限账号 + 列级视图,禁 DDL/FILE 权限        │
│ 信息层(断链) │ 生产环境错误信息不回显                           │
│ 边界层(纵深) │ WAF 监控+拦截、CI 静态扫描、注入渗透测试          │
└────────────┴────────────────────────────────────────────────┘

#{} vs ${} 决策规则

问自己:这个位置的值,语义上是"数据"还是"语法"?
├─ 数据(WHERE 条件值 / INSERT 值 / LIMIT 数值)
│   └→ #{} 预编译,永远不用 ${}
└─ 语法(ORDER BY / GROUP BY / 表名 / 列名 / ASC|DESC)
    └→ ${} + 白名单(枚举 / 别名映射 / 物理列校验)
        └→ 再加一层拦截器兜底 + 最小权限减伤

关键要点

  • 预编译的本质:语法树先固化,数据后进入——数据永远成不了语法
  • ORDER BY 注入的三种利用:CASE WHEN 盲注、报错注入、SLEEP 时间盲注——WAF 拦不住无特征的盲注
  • 白名单的正确姿势:用户输入被"消化"成枚举常量后才进 SQL,而不是"拼进 SQL 前校验一下"
  • 最小权限的性价比:即使被注入,没有 DDL/FILE/敏感列权限,杀伤力断崖下降
  • WAF 的定位:纵深防御的一层,假设它会被绕过,代码层防线必须独立成立

一句话

#{}${} 的区别,表层是"预编译与拼接",深层是"数据与语法的边界"——参数化只保护数据位,语法位(ORDER BY/表名/列名)是参数化天然够不到的地方,必须用白名单补上,再用最小权限和错误不回显把"万一失守"的杀伤力锁死。安全从来不是某一个机制,而是层层设防后剩下的风险容忍度。

给团队的建议

阶段建议
新项目团队规范写死:数据位一律 #{};语法位一律走排序白名单工具类
存量项目先全量扫 ${},数据位一周内清零,语法位补白名单
动态排序接口用 LambdaQueryWrapper / 别名映射,物理列名不出服务端
已有 WAF用本文 5.1 手法做穿透测试,验证规则有效性
长期机制CI 扫 Mapper XML 的 ${} + 权限最小化 + 错误信息治理

互动话题:你们的排序字段是怎么处理的?有没有在审计时被 ${} 吓一跳的经历?WAF 拦截和代码层参数化,你们更信哪个?评论区聊聊你们项目的 ${} 现状。


参考资料


标题:SQL 注入防护进阶:MyBatis #{} 和 ${} 的区别——不止是预编译
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/03/1788097868560.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消