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 | 错误信息不回显 | 切断报错注入的信息通道 | 全局异常处理器,生产环境返回通用错误码 |
| 7 | ORM 不等于免疫 | 动态排序/原生 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/**/LECT、sel%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 规则没覆盖的同义函数 | extractvalue→updatexml,substr→mid/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 拦截和代码层参数化,你们更信哪个?评论区聊聊你们项目的${}现状。
参考资料
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP Query Parameterization Cheat Sheet
- MyBatis 官方文档:动态 SQL
- MySQL 预编译语句文档
- Find Security Bugs:SQL Injection 检测规则
- MyBatis-Plus 条件构造器文档
- PortSwigger:Blind SQL Injection
标题:SQL 注入防护进阶:MyBatis #{} 和 ${} 的区别——不止是预编译
作者:jiangyi
地址:http://jiangyi.space/articles/2026/09/03/1788097868560.html
公众号:服务端技术精选
- 引言
- 一、从原理讲起:预编译为什么能防注入
- 1.1 SQL 注入的本质
- 1.2 PreparedStatement 预编译:代码和数据的边界
- 1.3 三个值得深究的细节
- 二、${} 为什么危险,又为什么不可替代
- 2.1 ${} 的真实行为
- 2.2 ${} 的合法使用场景:SQL 结构的组成部分
- 2.3 ORDER BY 注入:最常被忽视的洞
- 三、ORDER BY / GROUP BY 场景的安全实现
- 3.1 方案一:白名单校验(OWASP 首选)
- 3.2 方案二:排序字段映射表(前端传别名)
- 3.3 方案三:MyBatis 拦截器统一兜底
- 3.4 表名/列名动态场景:同样的思路
- 四、OWASP 的 SQL 注入防御检查表
- 4.1 防御清单(按优先级)
- 4.2 最小权限的实际配置
- 4.3 错误信息处理
- 五、常见绕 WAF 手法:知己知彼
- 5.1 常见绕过手法一览
- 5.2 一个具体例子:从拦截到绕过
- 5.3 WAF 的正确使用姿势
- 六、常见问题
- 6.1 #{} 一定安全吗?
- 6.2 开了预编译,为什么还要做输入校验?
- 6.3 MyBatis-Plus 的 QueryWrapper 会注入吗?
- 6.4 存储过程怎么防注入?
- 6.5 MyBatis 的 $ 会被 SonarQube/SpotBugs 扫出来吗?
- 6.6 已经线上了,怎么快速排查存量 ${} 风险?
- 七、总结
- 防御体系速查卡
- #{} vs ${} 决策规则
- 关键要点
- 一句话
- 给团队的建议
- 参考资料
评论