Engineering · Sep 1, 2026 · 6 min read

HTTP parameter pollution: when a duplicate key bypasses the gate

A request that carries the same key twice lets the WAF read one copy and the application act on the other - a three-step differential probe confirms which value the framework resolves and whether a security gate can be bypassed.

HTTP parameter pollution lands when a request carries the same key twice and the component that checks it disagrees with the component that acts on it. A WAF that reads the first occurrence waves through the payload in the second; an application that validates the first value passes the second to the business layer unchecked. Neither component is broken in isolation - the vulnerability lives in the gap between their conflicting resolution rules.

01Frameworks disagree on which value wins

No HTTP specification mandates how a server must handle duplicate query or body parameters. Each framework picked its own rule, which means an attacker controls where the winning value lands by choosing how many copies to send and in what order. The table below covers the six runtimes that appear most often in production stacks - and every row resolves differently.

Request: GET /items?id=10&id=99 Framework Resolved value of id Behaviour ------------------------------------------------------------------ PHP 99 last wins ($_GET superglobal) Java Servlet 10 first wins (getParameter) ASP.NET 10,99 joins with comma Node / Express 99 last wins (qs library default) Ruby on Rails 10 first wins (HashWithIndifferentAccess) Flask / Django 10 first wins (ImmutableMultiDict)
Same request, six different resolved values. A WAF and a backend parsing under different frameworks can read different copies of the same key.

The comma-join behaviour in ASP.NET is the hardest to handle: it turns a simple string into an unexpected multi-value format that may break a downstream parser in a different way from the original injection, sometimes triggering a secondary vulnerability rather than the primary one.

02The two common attack shapes

WAF bypass works when the WAF reads the first parameter and the application reads the last. Inject the benign value first and the payload second. The WAF sees the benign value and allows the request; the application acts on the payload. Input-validation bypass is the inverse: the application validates the first value but passes the second to the business layer without re-checking it. Both shapes produce the same confirming artifact - a server action that reflects the marker value, not the checked one.

hpp-probe.httphttp
# Baseline: single parameter, server validates and executes normally
GET /transfer?to=alice&amount=100 HTTP/1.1
Host: bank.example
Authorization: Bearer eyJ...

# Last-wins probe: safe value first, marker second
GET /transfer?to=alice&amount=100&amount=62615533 HTTP/1.1
Host: bank.example
Authorization: Bearer eyJ...

# If receipt shows 62615533 -> last-wins confirmed

# First-wins probe: marker first, safe value second
GET /transfer?to=alice&amount=62615533&amount=100 HTTP/1.1
# If receipt shows 62615533 -> first-wins confirmed

The nonce 62615533 is recognizable in audit logs without causing real harm - confirming on a staging environment avoids any side effect entirely. The probe reads no stored data and writes no new record; the confirming artifact is which value the server reflected in the receipt.

03Confirming with a three-step differential oracle

A single duplicate-key request is not enough evidence on its own. Some frameworks resolve both values as equal when the input happens to be identical, and a single probe cannot distinguish that from a true last-wins runtime. Three requests per parameter close the argument: a baseline with one value, a last-wins probe, and a first-wins probe. Only one of the two probe orders should diverge from the baseline - that order names the resolution rule the runtime applies.

Per-parameter probe set: Step 1 - Baseline: param=safe ──► record response R0 Step 2 - Last-wins: param=safe&param=62615533 ──► compare to R0 Step 3 - First-wins: param=62615533&param=safe ──► compare to R0 Step 2 diverges, Step 3 matches R0 ──► last-wins runtime Step 3 diverges, Step 2 matches R0 ──► first-wins runtime Neither diverges ──► parameter ignored or array-joined Both diverge ──► comma-join; secondary parsing risk
Three steps per parameter pin the resolution rule without modifying any stored state.
Proven - HighHTTP parameter pollution - amount override - /transfer
Step 1: GET /transfer?to=alice&amount=100 ──► 200 OK receipt:{amount:100} (baseline) Step 2: GET /transfer?to=alice&amount=100&amount=62615533 ──► 200 OK receipt:{amount:62615533} (last-wins confirmed) Step 3: GET /transfer?to=alice&amount=62615533&amount=100 ──► 200 OK receipt:{amount:100} (matches baseline)
Oracle: response body differential on receipt.amount. Nonce 62615533 confirmed as acting value in step 2 only - last-wins resolution proven. Severity high: attacker-controlled second value is executed by business logic with no re-validation. Escalates to critical if the target parameter governs access control or role.
Parameter typeHPP outcome if attacker value winsSeverity
Amount or quantityArbitrary substitution in business logicHigh
User ID or account refHorizontal access control bypass (BOLA path)Critical
Role or privilege flagVertical privilege escalationCritical
Redirect URLOpen redirect to phishing hostMedium
HMAC signature inputSigned value differs from executed valueHigh

04Scanner coverage and remediation

Automated DAST discovers HPP by injecting duplicate keys into every query and body parameter position and watching for a behavioral divergence against the baseline. The probe must vary order because first-wins and last-wins are both real runtime configurations. The confirming signal comes from the server's action, not from a reflected string - many JSON APIs do not echo parameters back into the body, so the scanner has to track business outcomes: status transitions, receipt fields, audit-log side effects observable without triggering a real transaction.

Remediation is always at the parsing layer. Reject requests that supply the same key more than once, or explicitly document which copy the application takes and configure every WAF rule and every validator to the same semantics. A one-line framework rule that converts duplicates into a 400 Bad Request before the handler runs is the only reliable fix; relying on the WAF to strip duplicates upstream trades a parsing bug for a WAF-bypass surface when the WAF and the backend interpret the key-strip differently.

6
major frameworks, six different resolution rules
3
probe requests per parameter to pin direction
0
records modified during a differential probe

The vulnerability is a proven behavioral gap, not a theoretical one. A differential that confirms the winning value across both probe orders closes the severity argument before the ticket reaches a developer - and the fix is a single parser-layer rejection rule, not a WAF pattern that the next framework update can silently undo.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →