Weaponizing the Cache#

Caching exists to serve responses faster and cheaper, but a cache is shared. If an attacker can make a cache store a response of their choosing, that response is then handed to everyone who requests the same thing. Web cache poisoning turns a performance layer into a mass-distribution vulnerability: one crafted request, one poisoned cache entry, many victims.

Its mirror image, web cache deception, does the opposite: it tricks the cache into storing a victim's private response where the attacker can read it.

Cache Keys and Unkeyed Inputs#

A cache decides whether two requests are "the same" by computing a cache key, usually from the URL path, query string and a small set of headers (often Host and Vary headers). Everything else about the request, headers the origin reads but the cache ignores, is an unkeyed input. Unkeyed inputs are the raw material of poisoning: if the origin reflects one into its response, an attacker can send a poisonous value, the cache stores the resulting response under a key that other users share, and every later request for that key gets the poison.

Headers frequently reflected by frameworks and proxies but left out of the cache key include:

X-Forwarded-Host
X-Forwarded-Scheme
X-Forwarded-Proto
X-Host
X-Original-URL
X-Rewrite-URL
Forwarded

For example, if an application builds an absolute URL for a script or a redirect from X-Forwarded-Host, a request carrying X-Forwarded-Host: attacker.example can cause the cached page to load its JavaScript from the attacker's domain. Because the page is served by the trusted origin, browsers and users treat that script as first-party.

A Worked Detection Walkthrough#

You detect poisoning the way a tester does, one careful step at a time, on a system you are authorised to test:

  1. Send a unique marker in a candidate unkeyed header and look for reflection:
GET /en/products HTTP/1.1
Host: shop.example
X-Forwarded-Host: canary123.example
  1. Check the response body for canary123.example. If it appears (say inside <link href="https://canary123.example/...">), the input is reflected.

  2. Confirm it is cacheable and keyed without your header. Send the request again without the header and inspect cache indicators:

HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Age: 42
X-Cache: HIT

An X-Cache: HIT (vendor-specific; may be cf-cache-status, X-Cache-Status, etc.) and a rising Age on the second request mean the response was served from cache. If the canary is now returned to a request that never sent the header, the cache is poisoned.

The delicate part is doing this without harming real users. Test against a URL variant nobody else uses, for example add a harmless unique query parameter (?cachebuster=canary123) so your poisoned entry is keyed to you, prove the mechanism, and only then reason about whether the real, shared key is vulnerable. PortSwigger's Param Miner Burp extension automates the search for unkeyed headers and cacheable reflections.

Escalation#

A poisoned entry is a delivery mechanism for whatever the reflection allows:

  • Stored XSS: if the reflected value lands in an HTML or JavaScript context, the attacker caches arbitrary script that runs for every visitor of that URL, bypassing many client-side defences because the origin served it. See XSS.
  • Redirect to phishing: reflected into a Location header or a client-side redirect, poison sends visitors to an attacker page under the real site's name.
  • Resource takeover: if the reflected value controls the source of an included script, stylesheet or import, the attacker controls executing code on the page.
  • Denial of service (cache poisoning DoS): reflecting a value that makes the origin return an error, then caching that error, can take a page down for everyone even without XSS.

Web Cache Deception#

Deception attacks the cache's decision to store, rather than a reflected input. The attacker crafts a URL that the origin treats as a private, authenticated page but the cache treats as a static asset. The classic form appends a static-looking suffix:

Real page:      https://bank.example/account
Attack URL:     https://bank.example/account/nonexistent.css

If the origin ignores the extra path segment and returns the victim's /account page, while the cache sees .css and decides "static, cacheable", then when the logged-in victim is lured into loading the attack URL, their personal page is stored in the shared cache. The attacker requests the same URL, unauthenticated, and reads the victim's data. Variants use .jpg, path parameters, encoded delimiters, and trailing slashes; the root cause is always a mismatch between how the origin maps the URL to content and how the cache decides cacheability.

Defences#

The fixes come down to removing the disagreement between cache and origin, and never caching anything personalised:

  • Do not reflect request headers into responses unless strictly necessary; when you must, validate and normalise them against an allowlist first.
  • Strip untrusted X-Forwarded-* and similar headers at the edge so only your own proxies can set them.
  • Make cacheability explicit. Decide what is cacheable by route and content type in configuration, rather than letting the cache guess from a file extension. This is the core defence against deception.
  • Include every input that changes the response in the cache key, or ensure inputs left out of the key cannot change the response.
  • Never cache authenticated or personalised responses in a shared cache. Mark them Cache-Control: private, no-store, and separate cache namespaces by authentication state.
  • Normalise the URL (resolve ./.., decode consistently, canonicalise trailing slashes) so origin and cache agree on what the path is.

A concrete origin-side guard against deception is to refuse to serve dynamic content under a static-looking path:

STATIC_SUFFIXES = (".css", ".js", ".png", ".jpg", ".gif", ".svg", ".ico", ".woff2")

def before_response(request, response):
    path = request.path.lower()
    if path.endswith(STATIC_SUFFIXES) and response.is_dynamic:
        # A static-looking URL returned personalised content: do not let it be cached
        response.headers["Cache-Control"] = "no-store"
    return response

Better still, route static and dynamic content through separate hostnames or path prefixes so the cache never has to guess.

Fat GET and Parameter Cloaking#

Two variations are worth recognising because they defeat naive defences:

  • Fat GET requests. Some frameworks read parameters from the body of a GET request as well as the query string. If the cache keys only on the URL but the origin reads a body parameter, that body parameter is an unkeyed input, and an attacker can vary the response without changing the cache key. The fix is to ignore request bodies on cacheable methods and to reject GET requests that carry a body.
  • Parameter cloaking and delimiter confusion. Cache and origin can disagree about how a query string is parsed, for example whether ; separates parameters, or how duplicate parameters are handled. An attacker exploits the disagreement so the cache keys on one interpretation while the origin acts on another. Normalising the query string identically at both layers, or excluding ambiguous constructs, closes this.

Both are instances of the same root cause as the rest of the lesson: the cache and the origin do not parse the request the same way.

Response-Header Reflection#

Not all poisoning flows through the body. If the origin copies a request header into a response header that influences later behaviour, for example reflecting into a Set-Cookie, a CORS Access-Control-Allow-Origin, or a Location, and that response is cached, every visitor receives the injected header. Reflecting an attacker-controlled origin into Access-Control-Allow-Origin on a cached response, for instance, can hand cross-origin read access to an attacker's site. Treat response headers built from request input with the same suspicion as reflected body content: validate against an allowlist, and never cache a response whose headers vary with an unkeyed request header.

The Underlying Principle#

Cache poisoning and deception share their root cause with HTTP request smuggling: a shared intermediary and an origin disagree about the meaning of a request, and an attacker exploits the gap to affect every user behind that intermediary. Whenever a caching or proxying layer sits between users and your app, treat "what does each side think this request means?" as a security question. The HTTP caching lesson covers how caches decide in the first place.

Practice#

On PortSwigger's Web Security Academy, complete the cache poisoning labs (unkeyed header, unkeyed query, and the cache-key normalisation ones) and at least one cache deception lab. For each, identify the single unkeyed input or key mismatch and the one configuration change that closes it. Then, on your own staging site, send an X-Forwarded-Host: canary.example and check whether it appears anywhere in the response body or headers.