HTTP 参数污染(HPP,HTTP Parameter Pollution)指攻击者在同一个请求中重复提交同名参数,利用不同中间件对参数的解析差异,让安全校验读到第一个值、业务逻辑读到最后一个值,从而实现金额篡改、权限绕过或 WAF 规则绕过。本文给出一套覆盖 Nginx、ModSecurity 与应用层(PHP / Java / Django)的完整检测与规范化配置,所有规则均可直接复制使用。
适用场景
- 电商、SaaS 等存在金额、数量、订单号等关键数值参数的站点,需要防止「校验用旧值、下单用新值」。
- 已部署 WAF 但仍出现规则被绕过的情况:多数 WAF 只检测单次出现的参数,重复参数可让规则匹配到无害值。
- 存在多级网关(CDN → Nginx → Tomcat/PHP-FPM)的架构,各层对同名参数的取值规则不一致。
- 需要对外提供 API,且接口参数直接参与鉴权判断(如
uid、role、tenant_id)。
前置条件
- Nginx 1.18 及以上(
map与if语法通用);如使用 OpenResty 1.19+,可直接启用本文的 Lua 方案。 - 已开启
access_log,用于后续的审计与告警。 - 能够修改站点 vhost 配置与应用代码,具备
nginx -t校验与回滚能力。 - 如使用 ModSecurity(v2.9 / v3),需已加载 OWASP CRS 基础规则。
原理说明:同名参数为什么会被「污染」
HTTP 协议并未规定同名查询参数出现多次时的语义,于是各实现各取所需:
- PHP(
$_GET):同名参数后者覆盖前者,最终只保留最后一个值;但tag=a&tag=b会被合并成数组。 - Java Servlet(
getParameter()):返回第一个值;取全部值需用getParameterValues()。 - ASP.NET / IIS:把同名参数值用逗号拼接成一个字符串(如
1,2)。 - Nginx(
$arg_x):返回第一个值;但$args保留原始字符串。 - Django / Flask:
request.GET.get()返回最后一个值,getlist()返回全部。
典型攻击链是:WAF 用「取第一个值」的逻辑做校验,业务代码用「取最后一个值」的逻辑执行业务。?price=1&price=9999 在 WAF 看来是 1 元,在下单逻辑里却是 9999 元。防御的核心有两条:一是拒绝重复参数,二是在应用层做参数规范化,二者缺一不可。
操作步骤
第一步:探测后端对重复参数的解析行为
在加固前先确认自己的后端取值规则,避免误判。准备一个回显脚本 /var/www/html/echo.php:
<?php
header('Content-Type: application/json');
echo json_encode([
'raw_query' => $_SERVER['QUERY_STRING'],
'get' => $_GET,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
然后构造重复参数请求:
curl -s 'https://example.com/echo.php?id=1001&id=1002'
curl -s 'https://example.com/echo.php?amount=1&amount=99999&action=pay'
观察返回的 get 字段:若 id 最终等于 1002(最后一个值),说明存在后覆盖行为,一旦上游校验使用第一个值,即构成参数污染风险。
第二步:Nginx 层拦截重复的关键参数
新建 /etc/nginx/conf.d/hpp.conf,用 map 在做路由匹配前完成检测。map 位于 http 上下文,conf.d/*.conf 默认已被包含,可直接放入:
# /etc/nginx/conf.d/hpp.conf
# 关键参数在同一个查询串中重复出现,即判定为 HTTP 参数污染
# (?!\[) 用于放行 tags[]=a&tags[]=b 形式的数组参数
map $args $hpp_hit {
default 0;
"~(?:^|&)(id|uid|user_id|tenant_id|role|price|amount|total|action|cmd|file|url|redirect|callback)(?!\[)=[^&]*&.*(?:^|&)\1=" 1;
}
其中关键点说明:$args 是不含前导问号的原始查询串,\1 反向引用第一个捕获组,保证「同一个参数名」重复;~ 前缀表示大小写敏感的正则匹配。
随后在站点 vhost 的 server 块中引用该变量:
server {
listen 443 ssl http2;
server_name example.com;
# HTTP 参数污染拦截:放在 location 之前,确保所有请求先经过
if ($hpp_hit) {
return 400;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 把原始参数串透传给应用层,便于应用二次校验
fastcgi_param ORIGINAL_QUERY_STRING $args;
}
}
如果使用 OpenResty,可用 Lua 替代正则,语义更清晰且不依赖反向引用:
# 站点 vhost 中,替代上面的 if 块
access_by_lua_block {
local critical = {
id=1, uid=1, user_id=1, tenant_id=1, role=1, price=1,
amount=1, total=1, action=1, cmd=1, file=1, url=1,
redirect=1, callback=1,
}
local seen = {}
for pair in string.gmatch(ngx.var.args or "", "([^&]+)") do
local name = ngx.unescape_uri(pair:match("^([^=]+)"))
if name and critical[name] then
if seen[name] then
ngx.log(ngx.ERR, "HPP blocked: ", name, " qs=", ngx.var.args)
return ngx.exit(400)
end
seen[name] = true
end
end
}
注意 ngx.unescape_uri 的作用:它能识破 %69d=1002 这类 URL 编码绕过,仅靠 Nginx 正则无法覆盖。
第三步:应用层参数规范化
网关层只能降低风险,无法替代应用自身的输入校验。以下三套实现任选其一,建议与本栈保持一致。
PHP:
<?php
const CRITICAL_PARAMS = [
'id','uid','user_id','tenant_id','role','price','amount',
'total','action','cmd','file','url','redirect','callback',
];
// 同名标量参数出现两次即拒绝,数组参数(以 [] 结尾)放行
function hpp_reject_duplicates(array $critical = CRITICAL_PARAMS): void
{
$qs = $_SERVER['QUERY_STRING'] ?? '';
$seen = [];
foreach (explode('&', $qs) as $pair) {
if ($pair === '') {
continue;
}
$name = urldecode(explode('=', $pair, 2)[0]);
if (substr($name, -2) === '[]') {
continue;
}
if (!in_array($name, $critical, true)) {
continue;
}
if (isset($seen[$name])) {
error_log('HPP blocked: ' . $name . ' qs=' . $qs);
http_response_code(400);
exit('Bad Request');
}
$seen[$name] = true;
}
}
// 读取参数时强制标量,杜绝「同名参数被合并成数组」导致的类型混淆
function param(string $name, $default = null)
{
$v = $_GET[$name] ?? $default;
if (is_array($v)) {
http_response_code(400);
exit('Bad Request');
}
return $v;
}
hpp_reject_duplicates();
$orderId = param('id');
Java Servlet 过滤器:
public class HppFilter implements Filter {
private static final Set<String> CRITICAL = Set.of(
"id", "uid", "user_id", "tenant_id", "role", "price",
"amount", "total", "action", "cmd", "redirect", "url", "callback"
);
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest r = (HttpServletRequest) req;
for (String name : CRITICAL) {
String[] values = r.getParameterValues(name);
if (values != null && values.length > 1) {
((HttpServletResponse) res).sendError(400, "Bad Request");
return;
}
}
chain.doFilter(req, res);
}
}
Django 中间件:
# app/middleware.py
from django.http import HttpResponseBadRequest
CRITICAL = {"id", "uid", "user_id", "tenant_id", "role", "price",
"amount", "total", "action", "cmd", "redirect", "url", "callback"}
class HppGuardMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
for name, values in request.GET.lists():
if name.rstrip("[]") in CRITICAL and len(values) > 1:
return HttpResponseBadRequest("Bad Request")
return self.get_response(request)
别忘了在 settings.py 的 MIDDLEWARE 列表中把 HppGuardMiddleware 放到尽可能靠前的位置。
第四步:ModSecurity 规则补充
若已部署 ModSecurity,可直接在 REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf 之外新建自定义规则文件 /etc/modsecurity/rules/hpp.conf:
# 阶段一即拦截,避免进入请求体解析
SecRule QUERY_STRING "@rx (?i)(?:^|&)(id|uid|user_id|tenant_id|role|price|amount|total|action|cmd|file|url|redirect|callback)(?!\[)=[^&]*&.*(?:^|&)\1=" \
"id:1001,phase:1,deny,status:400,t:none,t:urlDecodeUni,log,\
msg:'HTTP Parameter Pollution - duplicated critical parameter',\
severity:'CRITICAL',tag:'HPP',tag:'OWASP-CRS'"
t:urlDecodeUni 是关键:它把 %69d 还原为 id,弥补 Nginx 正则只看原始串的短板。规则改动后用 nginx -t 或 apachectl configtest 校验语法。
第五步:日志审计与告警
拦截规则上线后需要知道「谁在打」。下面的脚本从 access log 里统计重复关键参数的来源 IP:
#!/bin/bash
# 用法:./hpp_audit.sh /var/log/nginx/access.log
LOG=${1:-/var/log/nginx/access.log}
awk '
{
split($7, a, "?") # a[1]=路径,a[2]=查询串 + HTTP 版本
if (length(a) < 2) next
qs = a[2]
sub(/ HTTP\/.*$/, "", qs) # 去掉尾部 HTTP/1.1
n = split(qs, kv, "&")
delete seen; hit = 0
for (i = 1; i <= n; i++) {
split(kv[i], p, "=")
name = p[1]
if (name ~ /^(id|uid|role|price|amount|action|cmd|file|url|redirect|callback)$/) {
if (seen[name]++) hit = 1
}
}
if (hit) print $1, $7
}' "$LOG" | sort | uniq -c | sort -rn | head -20
输出第一列是出现次数,第二列是来源 IP。次数明显偏高的 IP 建议直接加入封禁列表。
配置验证
按以下四步逐条验证,顺序不可颠倒:
# 1) 语法校验
nginx -t
# 2) 正常单参数请求,期望 200
curl -s -o /dev/null -w 'single: %{http_code}\n' \
'https://example.com/api/order?id=1001'
# 3) 重复关键参数,期望 400
curl -s -o /dev/null -w 'duplicated: %{http_code}\n' \
'https://example.com/api/order?id=1001&id=1002'
# 4) URL 编码绕过,期望仍为 400
curl -s -o /dev/null -w 'encoded: %{http_code}\n' \
'https://example.com/api/order?id=1001&%69d=1002'
# 5) 数组参数,期望 200(不应误拦)
curl -s -o /dev/null -w 'array: %{http_code}\n' \
'https://example.com/api/search?tags[]=a&tags[]=b'
预期结果:single: 200、duplicated: 400、encoded: 400、array: 200。若第 4 项仍返回 200,说明缺少 ngx.unescape_uri 或 ModSecurity 的 urlDecodeUni,需回到第三、四步补齐。
常见问题(FAQ)
Q1:站点大量使用 tags[]=a&tags[]=b 数组参数,会不会被误拦?
不会。Nginx 正则中的 (?!\[)、PHP 实现里的 [] 后缀跳过逻辑、Django 的 rstrip("[]") 都专门放行了数组形式,只有同名标量参数重复才会被拦截。但如果前端把数组写成 tag=a&tag=b,该参数会被判定为重复,需要么改造前端,要么把该参数从关键参数清单中移出。
Q2:拦截率上不去,攻击者用编码或大小写绕过怎么办?
两种绕过各有对应措施。编码绕过(%69d):Nginx $args 保存的是未解码原始串,正则匹配不到,需依赖 OpenResty 的 ngx.unescape_uri 或 ModSecurity 的 t:urlDecodeUni。大小写绕过(id=1&ID=2):Nginx 侧把 ~ 改为 ~* 即可忽略大小写,但 PHP 的 $_GET 键名区分大小写,必须应用层统一 strtolower() 后再比对。
Q3:Nginx 层已经拦截了,为什么后端仍然收到重复参数?
三个常见原因:一是 if ($hpp_hit) 被放在了 location 之后或某个 rewrite 之后,请求提前被处理;二是站点前置了 CDN 或云 WAF,它已对 $args 做了改写(例如拼接了跟踪参数),Nginx 看到的不是原始串,需在 CDN 层同步配置同一规则;三是 map 定义在 http 上下文但未被加载,可用 nginx -T | grep hpp_hit 确认变量是否生效。
总结
HTTP 参数污染的根因是「同名参数没有统一语义」,因此单点防御一定不够。推荐的落地顺序是:Nginx / ModSecurity 在边缘拒绝重复关键参数 → 应用层强制参数取值规范化并拒绝数组类型 → 审计脚本兜底发现漏网请求。三层叠加后,?price=1&price=9999 这类请求在到达业务逻辑之前就会被拦下,金额与权限类参数不再有被「污染」的机会。