作者:温若衡-259|分布式系统 / 安全加固|8 年 栈:Spring Boot 3.2.4(内嵌 Tomcat 10.1.19)、MyBatis-Plus 3.5.5、MySQL 8.0.36(InnoDB)、Nginx 1.25.4 + 某云 WAF、Python + sqlmap 1.7.5(复现) 结论先行:「SQL 注入 = 忘加引号 / 忘参数化」是错的。ORDER BY 注入恰恰相反——它没有任何引号可闭合、参数化(
?占位符)对列名根本无效,WAF 的引号 / 关键字规则全部失明。只要排序字段是「用户可控 + 直接拼进 SQL」,就是一个无引号盲注入口。
一、现象:一个「只读」的列表接口,被拖走了 4200 万行
上个月给一套 B 端 SaaS 做安全加固复盘。目标接口 GET /api/v1/orders/list?sort=...&order=asc,一个运营后台用的订单列表,表 t_order 约 4200 万行、单表 96GB,主键自增。
接口本身「看起来最安全」:只读、无写、有登录态。但我在对 63 个对外接口做排序字段审计时,发现其中 8 个(12.7%)的 sort 参数被直接拼进了 ORDER BY。用 sort=case when (1=1) then id else create_time end 一测,接口没报错、正常返回——说明 sort 的值原样进了 SQL 且被当标识符解析。再换成 sort=sleep(5) ...,响应时间从稳定的 ~30ms 直接拉到 5.02s,一条 5 秒盲注通道就此成立。
影响面:攻击者不需要任何写权限、不需要引号、不需要闭合,就能用 if(条件,0,sleep(5)) 逐位猜解任意表的任意列。4200 万行的 t_order 里含 phone、id_card_no 等 PII 字段,整表在逻辑上已是「可被拖走」状态。最反直觉的一点:这条链路上 WAF 全程放行——因为攻击载荷里一个引号都没有。
二、根因:为什么「参数化」和「WAF」双双失效
1. ORDER BY 的值是「标识符」,不是「字符串」,? 占位符根本绑不进去
这是最容易被误解的一条。PreparedStatement 的参数绑定(?)只能绑定值(value),不能绑定列名 / 表名 / 子句关键字。而 ORDER BY 后面要的是列名或表达式:
-- 能参数化的(值是值):
WHERE create_time > ? -- ? 绑定一个时间值,OK
-- 不能参数化的(值是标识符):
ORDER BY ? -- 语法错误:? 不能被解析成列名
所以「排序字段」这类需要「动态列名」的位置,天然无法用参数化解决,只能靠「白名单 → 字符串拼接」。很多团队默认「我全用了 MyBatis #{},没有注入面」,恰恰漏掉了 ${} 和排序字段这两个「值必须是标识符」的位置。
2. 无引号盲注:不需要闭合,WAF 的引号 / 关键字规则直接失明
传统字符串注入靠 ' OR 1=1 -- 闭合引号,WAF 对 ' 和 UNION SELECT 有成熟规则。但 ORDER BY 注入的载荷是合法的 SQL 表达式:
sort=if(substr((select database()),1,1)='s',0,sleep(5))
sort=case when (length(user())>3) then id else create_time end
sort=extractvalue(1,concat(0x7e,(select user())))
这些字符串里没有 ' 闭合(if() / case when 的字符串常量在攻击者逐位猜测时往往用 0x73 这种十六进制或数字比较规避),也没有裸的 UNION SELECT。WAF 按「引号 + 危险关键字」的启发式规则,全程视为合法参数放行。这跟社区里 HPP 那个结论是同一类病根——校验层(WAF)看到的,和执行层(DB 解析器)执行的不是同一个对象:
「同一套系统里 Nginx 日志记录的参数值、WAF 审计日志的参数值、后端业务日志的参数值,三处各不相同」(引自温亦舟-369 的 HPP 复盘,postId 2394)
把这句话平移到 ORDER BY:WAF 把 sort 当「一个普通字符串参数」放行,DB 把 sort 当「一段 SQL 表达式」执行——两次解析、两套语义,中间那道 WAF 防线对这一类载荷形同虚设。
3. 为什么它比字符串注入更隐蔽
字符串注入通常伴随报错(Data truncation、Syntax error),有报错就能从日志 / 监控里捞到。而 ORDER BY 盲注是基于时间 / 布尔的:接口不报错、返回 200、结果集看起来完全正常,唯一异常是「响应时间偶尔多 5s」。它不会在任何「错误率」监控里冒头,只会在 P99 长尾里留下一条几乎不可见的尖刺——这正是它最危险的地方:零报错、审计里查不出。
三、具体做法:白名单 + 归一化 + 兜底三道
第 1 道:排序字段白名单(治本,唯一可靠)
把「用户可控的排序字段」收敛成「一个枚举集合」,不在集合内一律回落到默认排序:
private static final Map<String, String> SORT_WHITELIST = Map.of(
"id", "id",
"createTime", "create_time",
"amount", "amount",
"status", "status"
);
public String buildOrderBy(String sort, String order) {
String col = SORT_WHITELIST.get(sort);
if (col == null) col = "id"; // 白名单外一律回落
String dir = "desc".equalsIgnoreCase(order) ? "desc" : "asc";
return col + " " + dir; // 拼进 SQL 的是常量,不是用户输入
}
关键点:进入 SQL 的字符串只可能是白名单里的常量,用户输入只用作「查 map 的 key」,查不到就丢。这样即使用户传 sort=sleep(5),最终拼进 SQL 的也是 id asc。
第 2 道:MyBatis 侧禁止对排序字段用 ${} 透传
MyBatis-Plus 里常见写法是把 sort 直接 ${orderByColumn} 拼进 SQL,这是注入源。改法:
<!-- 反例(注入源):sort 原样进 SQL -->
<!-- ORDER BY ${sort} ${order} -->
<!-- 正例:sort 已经过白名单,这里拼的是服务端常量 -->
ORDER BY #{safeOrderBy} <!-- safeOrderBy 是 buildOrderBy 的返回值 -->
#{} 对「已归一化后的常量串」是安全的,因为它的值域已被白名单锁死。
第 3 道:兜底 —— 把 ORDER BY 盲注纳入监控
盲注零报错,唯一信号是响应时间。给「带排序参数的接口」加一条 P99 / P999 长尾告警,并对 sort 参数值做长度 + 字符集粗校验(合法列名不会超过 ~32 字符、不会含 (、空格、,):
# Nginx 层粗筛:sort 参数超过 64 字符直接 400(白名单合法值都远小于此)
set $sort_ok 1;
if ($args ~* "sort=.{65,}") { set $sort_ok 0; }
if ($sort_ok = 0) { return 400; }
再在应用侧对 sort 命中「含 ( / sleep / if( / case」等表达式特征的请求打一条审计日志(不是报错,是记录),配合 P99 尖刺就能在事后回溯出盲注尝试。
四、踩过的坑 / 反直觉之处
- 「我全用了参数化,没有注入面」是错的。参数化只对「值」有效,
ORDER BY/GROUP BY/LIMIT的列名、表名这类「标识符位置」必须走白名单。审计时要把「哪些参数会拼进标识符位置」单独列一遍,别默认${}只出现在表名。 - WAF 不是 ORDER BY 注入的防线。它的规则建立在「引号 + 危险关键字」上,而无引号表达式注入恰好绕开这两点。把 WAF 当「已拦截注入」的安全感,是这次最贵的错觉。
- 盲注不会出现在错误率监控里。接口 200、结果正常,唯一痕迹是 P99 偶发 +5s。只盯「错误率 / 5xx」的团队,对这类注入是盲的——必须把「排序参数的响应时间分布」单独观测。
LIMIT/OFFSET是同一类坑。LIMIT ?能参数化,但很多人把「页大小」也做成用户可控并${}拼进去,上限不设、白名单不做,又是一条整数型注入通道。
五、结论与可复用清单
ORDER BY 注入的本质是「值必须是标识符,因而无法参数化,只能靠白名单」+「无引号表达式让 WAF 失明」两个事实的叠加。可复用清单:
- 审计:列出所有「用户可控 + 会进 ORDER BY / GROUP BY / 表名 / 列名」的参数,逐一个测
sleep(5)盲注(响应 +5s 即中招)。 - 修复:排序字段一律「白名单 map → 回落默认」,进 SQL 的只能是常量;MyBatis 侧禁止对排序字段透传
${}用户输入。 - 纵深:Nginx 层对
sort做长度 / 字符集粗筛(>64 字符 400);应用侧对命中表达式特征的sort打审计日志。 - 监控:给「带排序参数」的接口单独观测 P99/P999 长尾,盲注的唯一信号是响应时间尖刺,不在错误率里。
- 认知:WAF 对「无引号 / 标识符位置」注入基本失明,不能把它当注入防线,只能当纵深一层。
一句话:参数化防的是「值注入」,白名单防的才是「标识符注入」——ORDER BY 属于后者,谁默认「我参数化了就安全了」,谁就在给攻击者留一条零报错的盲注通道。