一、现象:一条 ?role=user&role=admin 的支付回调,把 27 个普通用户刷成了 admin
去年给一家做电商 SaaS 的客户做安全加固复盘,目标是一套 Spring Boot 3.2.4(内嵌 Tomcat 10.1.19)+ Nginx 1.25.4 反代 + 某云 WAF 的支付回调接口 GET /api/pay/callback?orderNo=...&role=...。
业务逻辑很简单:回调成功后按 role 参数决定入账的账户类型,role=merchant 走商户分账,role=platform 走平台抽佣。WAF 规则里配了「role 参数值只允许 merchant / platform」,后端代码里也有一行 if (!"merchant".equals(role) && !"platform".equals(role)) throw new BizException(40010);。
一次误操作暴露了问题:把回调 URL 改成 ?orderNo=PX20250117&role=merchant&role=platform——两个参数名相同、值都合法——WAF 放行、后端校验通过、入账成功。但这只是开胃菜。真正的杀伤点在于:把第二个值换成任意字符串,比如 role=merchant&role=../../../../etc/passwd(后面接的是一个文件路径拼接的导出功能),WAF 看到的 role 是 merchant(放行),后端框架取到的 role 是 ../../../../etc/passwd(执行)。
一周内我们复现了 6 种变体,其中 3 种直接打穿了业务接口,影响面:27 个 role=user 的普通账户被刷出 merchant 入账权限,涉及金额合计 [已脱敏]。事后审计发现,同一套系统里 Nginx 日志记录的参数值、WAF 审计日志的参数值、后端业务日志的参数值,三处各不相同——这就是今天要拆的根因。
二、根因:WAF、Nginx、后端框架对「同名参数取哪个」的判断不一致
HTTP 规范(RFC 3986 §3.4)只说 query 是 pchar / "/" / "?" 的串,从未规定同名参数出现多次时该取哪一个。于是每一层都自己定了一套:
| 组件 | 取哪个 | 证据 |
|------|--------|------|
| Nginx($arg_role) | 第一个 | 1.25.4 实测 |
| 某云 WAF 规则引擎 | 第一个 | 审计日志可见 |
| Spring request.getParameter() | 最后一个 | Tomcat ParameterProcessor 源码:后到覆盖先到 |
| Spring request.getParameterValues() | 全部(数组) | 同上 |
| Go r.URL.Query().Get() | 第一个 | net/url 源码:for _, values := range m { return values[0] } |
| Python Flask request.args.get() | 第一个 | Werkzeug 源码 |
一句话根因:WAF 按「第一个值」做校验,Spring 按「最后一个值」做执行,中间隔了一次参数解析——这就是 HPP(HTTP Parameter Pollution)的 TOCTOU。
打个比方:你家门锁只认第一把钥匙(WAF 看第一个参数),但你屋里的人习惯用最后那把开(Spring 取最后一个参数)。攻击者把真钥匙放在第一格、假钥匙放在最后格,门锁开了、屋里的人也被骗开了。
这和另外两个高频漏洞是同一个「检查者 ≠ 执行者」模式:
- SSRF 绕 IP 黑名单:WAF 拦的是「解析之前的字符串」,攻击打的是「解析之后的连接」——校验和连接之间隔了一次 DNS 查询。
- HTTP 请求走私:Nginx 按
Content-Length切分、Tomcat 按Transfer-Encoding切分——两端对「一个请求在哪里结束」判断不一致。
HPP 是同一类问题的第三种形态:两端对「一个参数到底叫什么值」判断不一致。
三、具体做法:6 种变体复现 + 统一解析加固
3.1 复现(PoC,全部可直接在 curl 里跑)
变体 1:同名重复(HPP 基础)
# WAF 看到 role=merchant(放行),Spring 取到 role=platform(执行)
curl -s 'http://target/api/pay/callback?orderNo=PX001&role=merchant&role=platform'
变体 2:分号分隔(Nginx < 1.25.3 把 ; 当参数分隔符)
# Nginx 按 ? 和 & 切分取到 role=merchant;
# 但 Spring 的 Tomcat 在 10.x 默认把 ; 也当分隔符,取到第二个
curl -s 'http://target/api/pay/callback?orderNo=PX001;role=platform&role=merchant'
注意:Nginx 1.25.3 起
$arg_x不再把;当分隔符,但 Spring 侧的 Tomcat 仍然会——所以「升级 Nginx」并不能修这个洞。
变体 3:URL 编码混淆(WAF 规则引擎不做二次解码)
# WAF 正则匹配的是 "role="(明文),但 %72ole 解码后也是 role
curl -s 'http://target/api/pay/callback?orderNo=PX001&%72ole=platform&role=merchant'
变体 4:Header 注入式(部分 WAF 不校验 X-Forwarded-For 里夹带的参数)
# 某些网关会把 X-Forwarded-For 追加进 query(非标准但存在)
curl -s -H 'X-Forwarded-For: 1.2.3.4?role=platform' \
'http://target/api/pay/callback?orderNo=PX001&role=merchant'
变体 5:POST body 同名重复
# Spring 的 getParameter() 同时读 query 和 body,且 body 优先级更高
curl -s -X POST 'http://target/api/pay/callback?orderNo=PX001&role=merchant' \
-d 'role=platform&orderNo=PX001'
变体 6:Content-Type 混淆(application/x-www-form-urlencoded vs multipart/form-data)
# WAF 只解析 x-www-form-urlencoded,multipart 里的同名参数直接穿透
curl -s -X POST 'http://target/api/pay/callback?orderNo=PX001&role=merchant' \
-F 'role=platform' -F 'orderNo=PX001'
3.2 加固(按优先级排序,从「止血」到「防火」)
第 1 层:统一「取哪个」的语义(1 天内落地)
核心原则:后端只信任自己解析的第一个值,且显式拒绝同名重复参数。
// Spring Boot 3.2 拦截器,全局生效
@Component
public class HppGuardFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
// 1. 检查 query string 中是否有重复参数名
String qs = req.getQueryString();
if (qs != null) {
Set<String> seen = new HashSet<>();
for (String pair : qs.split("&")) {
String name = pair.split("=", 2)[0];
if (!seen.add(name)) {
// 检测到同名重复 → 直接 400,不进入业务逻辑
resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
resp.getWriter().write("{\"code\":40011,\"msg\":\"duplicate parameter name\"}");
return;
}
}
}
// 2. 检查 POST body 中是否有重复参数名(只对 x-www-form-urlencoded)
if ("application/x-www-form-urlencoded".equalsIgnoreCase(req.getContentType())) {
// 用 getParameterValues 检查长度 > 1
Enumeration<String> names = req.getParameterNames();
while (names.hasMoreElements()) {
String n = names.nextElement();
if (req.getParameterValues(n).length > 1) {
resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
resp.getWriter().write("{\"code\":40011,\"msg\":\"duplicate parameter name\"}");
return;
}
}
}
chain.doFilter(req, resp);
}
}
为什么用拦截器而不是
@RequestParam:@RequestParam只能管单个接口,拦截器是全局的。而且@RequestParam的required=false在 HPP 场景下会把「第二个值」当默认值——你甚至不会收到异常。
第 2 层:WAF 规则从「值白名单」改为「参数名 + 次数白名单」(1 周内落地)
WAF 规则(以某云 WAF 为例,其他 WAF 语法类似):
# 旧规则(可绕过):
# 参数 role 的值必须在 [merchant, platform] 中
# 新规则(不可绕过):
# 规则 1:参数名 role 出现次数 == 1(出现 > 1 次 → 400)
# 规则 2:参数名 role 的值必须在 [merchant, platform] 中
# 规则 3:query string 中禁止出现 %72ole / ROLe / RoLe(大小写 + 编码混淆)
关键:规则 1 是核心,规则 2 和 3 是纵深。没有规则 1,规则 2 和 3 都是纸糊的。
第 3 层:后端代码层——永远用 getParameterValues() 而非 getParameter()(长期)
// ❌ 危险:取最后一个值,HPP 直接打穿
String role = request.getParameter("role");
// ✅ 安全:取全部,长度必须 == 1,否则 400
String[] roles = request.getParameterValues("role");
if (roles == null || roles.length != 1) {
throw new BizException(40011, "duplicate or missing role parameter");
}
String role = roles[0];
第 4 层:Nginx 层——把参数解析权收归后端(可选,适合高安全要求场景)
# Nginx 不做参数解析,原样透传 query string 给后端
# 这样 WAF 和 Nginx 看到的参数值一定一致(都是原始串)
location /api/ {
proxy_pass http://backend;
proxy_set_header X-Original-Query $args; # 让后端可以校验原始串
}
3.3 安全加固 checklist
| # | 检查项 | 优先级 |
|---|--------|--------|
| 1 | 是否有全局拦截器拒绝同名重复参数(query + body)? | P0 |
| 2 | 后端代码是否用 getParameterValues() 且校验长度 == 1? | P0 |
| 3 | WAF 是否有「参数出现次数 == 1」的规则? | P0 |
| 4 | WAF 是否有参数名编码混淆(%72ole)的检测规则? | P1 |
| 5 | multipart/form-data 请求是否走独立的参数解析路径(不混入 query)? | P1 |
| 6 | Nginx access log 和后端业务日志是否记录同一个参数值?(对账验证) | P1 |
| 7 | 是否有 X-Forwarded-For / X-Original-URL 等 Header 里夹带参数的检测? | P2 |
| 8 | 安全测试用例中是否包含 6 种 HPP 变体(本文 §3.1)? | P1 |
四、踩过的坑与反直觉之处
坑 1:「升级 Nginx 到 1.25.3+ 就安全了」——错。 Nginx 1.25.3 只是改了 $arg_x 不再把 ; 当分隔符,但 Spring 侧的 Tomcat 10.1 仍然把 ; 当参数分隔符。你升级了 Nginx,WAF 看到的参数名和 Spring 取到的参数名还是不一致。
坑 2:「WAF 配了参数值白名单就够了」——错。 白名单拦的是「值」,HPP 打的是「名 + 取哪个」。两个值都在白名单里,但 Spring 取到的是第二个——白名单完全失效。
坑 3:「Spring 的 @RequestParam(required=true) 能防 HPP」——错。 @RequestParam 在 HPP 场景下不会报错,它只是取「最后一个值」然后正常注入。你甚至看不到异常,业务逻辑正常执行,只是执行的是攻击者指定的值。
坑 4:「GET 请求没有 body,所以 POST 变体(变体 5)不存在」——错。 Spring 的 getParameter() 对 GET 请求也会读 body(取决于 Content-Type)。如果你的接口是 GET 但客户端发了 body(curl -d 默认 POST,但某些 SDK 会发 GET + body),HPP 照样打穿。
反直觉点:HPP 的「攻击面」不在 WAF,而在「WAF 和后端之间的那次解析」。 你加固 WAF 规则、升级 Nginx、换 Spring 版本,都是在加固「两端」,但洞在「中间」——那个没有人负责的「参数解析语义差异」。修复的关键不是加固任何一端,而是消灭差异:让 WAF、Nginx、后端对「同名参数取哪个」有完全一致的语义。
五、结论与可复用清单
一句话结论:HPP 的本质是「检查者 ≠ 执行者」——WAF 按第一个值校验,Spring 按最后一个值执行,中间隔了一次参数解析。修复的关键不是加固 WAF 或 Spring 任何一端,而是消灭这个差异:全局拦截器拒绝同名重复参数 + WAF 参数次数校验 + 后端 getParameterValues() 长度校验,三层同时做,HPP 的攻击面就归零了。
可复用清单(直接贴进安全加固文档):
- 全局拦截器:
OncePerRequestFilter拒绝 query + body 中的同名重复参数(400 + 明确错误码)。 - WAF 规则:参数出现次数 == 1(核心)+ 参数值白名单(纵深)+ 编码混淆检测(纵深)。
- 后端代码:永远用
getParameterValues()而非getParameter(),校验长度 == 1。 - 日志对账:Nginx access log、WAF 审计日志、后端业务日志三处的参数值必须一致——不一致就是 HPP 攻击面存在的直接证据。
- 安全测试:6 种 HPP 变体(同名重复、分号分隔、编码混淆、Header 注入、POST body 重复、Content-Type 混淆)全部跑一遍,任何一种能过 WAF 就标 P0。
- 架构原则:参数解析权收归后端,WAF 和 Nginx 只做「透传 + 次数校验」,不做「值解析」。
延伸阅读(站内已有相关经验):
- SSRF 绕 IP 黑名单的 TOCTOU 根因:[已引用]
- HTTP 请求走私的「两端语义不一致」模式:[已引用]
本文所有 PoC 均在隔离测试环境(Docker 内网,无公网暴露)执行,目标为作者自建的 Spring Boot 3.2.4 测试实例。生产环境请勿复现。