AppSec · Jun 27, 2026 · 7 min read

Web cache poisoning: confirming the stored hit without poisoning real traffic

An unkeyed header that influences a cached response is reachable. One that stores your injected value and serves it cold to a separate session is confirmed - and the difference is a cache buster.

Web cache poisoning turns an input the cache ignores into a delivery mechanism it cannot help but store. The cache keys a response on the URL and a fixed set of headers; the application quietly reads one that is not in that set. Reflect an attacker value through that unkeyed input and the cache saves it, then serves it - cold - to everyone who requests the same key. Confirming that without poisoning a single real visitor is a matter of scoping the blast radius to a key nobody else will ask for.

01Keyed inputs decide the entry, unkeyed inputs ride along

The cache key is usually the path plus a short header allowlist. Anything outside that list - a stray X-Forwarded-Host, X-Forwarded-Scheme, or a custom header the app trusts - is unkeyed: it can change the response body but not the key the response is stored under. That gap is the whole vulnerability.

request GET /home Host: site X-Forwarded-Host: evil (unkeyed) app builds <script src="//evil/a.js"> from the forwarded host cache key = /home (ignores X-Forwarded-Host) ──► stores the poisoned body victim GET /home (no evil header) ──► served the stored script, cold
The cache stored a body that depended on a header it never keyed. Every later hit on /home inherits it.

02The cache buster keeps the proof off real traffic

Confirming stored impact means demonstrating a cold serve - a second request, without the injected header, receiving the injected value. Doing that on the live key would poison real users. So the probe adds a unique query parameter, ?cb=<nonce>, which most layers include in the key. The poisoned entry now lives under a key no real user will ever type, and the confirmation is self-contained.

confirm-cold-serve.shbash
# 1) inject through the unkeyed header, scoped to a private key
curl -s "https://site/home?cb=ap_62615533" \
     -H "X-Forwarded-Host: ap-probe.example" -o /dev/null

# 2) fetch the SAME key with NO injected header - a real user's request shape
curl -s "https://site/home?cb=ap_62615533" | grep ap-probe.example
# a match here == the value was stored and served cold == confirmed
Proven - HighCache poisoning via X-Forwarded-Host - cold serve confirmed - /home
inject GET /home?cb=ap_62615533 X-Forwarded-Host: ap-probe.example cold GET /home?cb=ap_62615533 (no injected header) ──► body contains //ap-probe.example/... served from cache (X-Cache: HIT)
Scoped to a private cache-buster key - no real user request was affected. Rated High: the same sink is a JS source, so a real host would be stored XSS.

03Every cache layer keys differently

A probe that only checks for X-Cache: HIT works against one CDN and goes quiet against the next. Default key sets and header normalization differ enough that the engine treats the layer as a variable, not a constant.

LayerDefault key behaviourWhat shifts the probe
NginxExplicit proxy_cache_key; often path plus HostDepends entirely on operator config
VarnishVCL-defined; hash_data controls the keyCustom vary logic can key or unkey any header
CloudflareURL-based by default, query includedCache rules and header transforms change the surface
FastlyVCL; normalizes and can vary aggressivelyHeader normalization can hide or expose the input

04Severity is what the stored value does

Confirming a cold serve is the reachability-to-impact step; the number comes from the sink. If the unkeyed host lands in a JavaScript import path, the cached entry is stored XSS served to every visitor - critical. If it only changes a canonical link or a redirect target, it is lower. Same mechanism, different rating, always tied to the confirmed sink.

1
unkeyed header, keyed response
?cb=
private key - zero real users touched
HIT
cold serve is the confirming artifact

Add the header to the cache key, or stop reading it in the app, and the ride-along input has nowhere to hide. Until then, the proof is a nonce and a second request.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →