HTTP 参数污染防御实战:Nginx 与后端参数校验配置

HTTP 参数污染(HPP,HTTP Parameter Pollution)指攻击者在同一个请求中重复提交同名参数,利用不同中间件对参数的解析差异,让安全校验读到第一个值、业务逻辑读到最后一个值,从而实现金额篡改、权限绕过或 WAF 规则绕过。本文给出一套覆盖 Nginx、ModSecurity 与应用层(PHP / Java / Django)的完整检测与规范化配置,所有规则均可直接复制使用。

适用场景

  • 电商、SaaS 等存在金额、数量、订单号等关键数值参数的站点,需要防止「校验用旧值、下单用新值」。
  • 已部署 WAF 但仍出现规则被绕过的情况:多数 WAF 只检测单次出现的参数,重复参数可让规则匹配到无害值。
  • 存在多级网关(CDN → Nginx → Tomcat/PHP-FPM)的架构,各层对同名参数的取值规则不一致。
  • 需要对外提供 API,且接口参数直接参与鉴权判断(如 uidroletenant_id)。

前置条件

  • Nginx 1.18 及以上(mapif 语法通用);如使用 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 / Flaskrequest.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.pyMIDDLEWARE 列表中把 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 -tapachectl 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: 200duplicated: 400encoded: 400array: 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 这类请求在到达业务逻辑之前就会被拦下,金额与权限类参数不再有被「污染」的机会。