Caches exist for one thing: to deliver the same response to many people without bothering the origin server on every request. A CDN, a reverse proxy (Varnish, Nginx, Cloudflare), or even the browser cache stores a response and re-delivers it to anyone who asks for “the same resource.” The problem is born right there: the definition of “the same resource” is rarely as precise as the developer imagines. When the attacker manages to influence what gets stored — or trick the cache about what can be stored — they turn a performance layer into an attack layer.

There are two distinct families that exploit this mismatch. In Web Cache Poisoning, the attacker injects malicious content into a cache entry that then gets served to every user. In Web Cache Deception, they trick the cache into storing an authenticated page belonging to the victim, which they later retrieve anonymously. They are opposite mechanisms — one pushes garbage in, the other pulls secrets out — but both spring from the same root: the way the cache decides what counts as “the same” response.

How it works: the cache key and the unkeyed inputs

A cache does not compare requests byte by byte. It computes a cache key from a subset of the request and uses that key as an index. Typically the key includes:

  • the method (GET),
  • the host (Host or the HTTP/2 :authority),
  • the path (/produtos/123),
  • and, sometimes, a few selected query parameters.

Everything that goes into the key is keyed. Everything that does not is unkeyed. And here is the exploitable detail: several unkeyed headers affect the response generated by the application, even without being part of the key. The cache treats two requests with identical Host and path as “the same,” ignoring that a header like X-Forwarded-Host completely changed the HTML produced.

Consider an application that builds absolute URLs from a proxy header:

GET /pt/sobre HTTP/1.1
Host: loja.exemplo.com
X-Forwarded-Host: loja.exemplo.com

The application, sitting behind the CDN, uses X-Forwarded-Host to generate canonical links and tags such as:

<link rel="canonical" href="https://loja.exemplo.com/pt/sobre" />
<script src="https://loja.exemplo.com/static/app.js"></script>

The X-Forwarded-Host header is not in the cache key, but it is reflected in the response. That is the perfect trigger for poisoning.

The diagnostic headers

Before exploiting, you need to confirm that a cache exists and read what it says about each response. The headers that matter most:

  • X-Cache: hit / X-Cache: miss — indicates whether the response came from the cache (hit) or the origin (miss). It is not standardized; each stack uses its own name: cf-cache-status (Cloudflare), X-Cache-Status (Nginx), X-Served-By/X-Cache (Fastly/Varnish).
  • Age: 137 — standard HTTP header (RFC 9111): how many seconds the response has been in the shared cache. If it grows on every request to the same path, it is the same cached entry being re-delivered.
  • Cache-Control — origin directive that governs cacheability (public, private, no-store, no-cache, max-age, s-maxage).
HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Age: 42
X-Cache: hit

This set is your instrument. If you send a request and get X-Cache: miss, send it again and get X-Cache: hit with a growing Age, you are looking at a cached response — and anything that made its way into it the first time will be re-delivered to subsequent victims.

The anatomy of Web Cache Poisoning

Poisoning happens in two stages. First, the attacker makes a request that injects a payload through an unkeyed input. The origin reflects that payload in the response and the cache stores it under the “clean” key (normal path, nothing suspicious in the key). Second, any victim who requests that same path receives the poisoned response from the cache, without ever touching the original payload.

The poisoning request:

GET /pt/sobre HTTP/1.1
Host: loja.exemplo.com
X-Forwarded-Host: atacante.exemplo-evil.com

If the application reflects X-Forwarded-Host without validating it, the response now carries a resource from an attacker-controlled domain:

<script src="https://atacante.exemplo-evil.com/static/app.js"></script>

The cache key is still GET loja.exemplo.com /pt/sobre — identical to a legitimate user’s. The cache stores this poisoned response. From that moment on, every visitor to /pt/sobre receives the <script> pointing to the attacker’s server, which serves arbitrary JavaScript in the context of the trusted origin. This is persistent XSS delivered at CDN scale, without requiring a single interaction from the victim.

Testing note. In practice, before poisoning the real key, use a cache buster — a unique query parameter, e.g. /pt/sobre?cb=12345 — to isolate a key that is exclusively yours during discovery. Only after confirming reflection and cacheability do you attack the “clean” path that victims actually access. In an authorized environment, this avoids accidentally taking down the service and keeps you in control of what is poisoned.

Confirming the poisoning with the diagnostic headers — the response re-delivered to other users comes back with X-Cache: hit and a growing Age, proving that the payload is pinned in the cache and not being regenerated on every request:

HTTP/1.1 200 OK
Cache-Control: public, max-age=300
Age: 95
X-Cache: hit
...
<script src="https://atacante.exemplo-evil.com/static/app.js"></script>

The <script> is the most severe case, but the same mechanism works for open redirect (poisoning a Location or a meta-refresh to send everyone to a phishing site), denial of service (reflecting a header that breaks rendering and caching the broken page), and content injection in general.

Variations and bypasses

Unkeyed headers beyond the obvious. X-Forwarded-Host is the classic one, but the list is long and varies by stack: X-Host, X-Forwarded-Scheme, X-Forwarded-Proto, X-Forwarded-Server, X-Original-URL, X-Rewrite-URL, Forwarded, X-Forwarded-Port. An especially useful pair is X-Forwarded-Scheme combined with X-Forwarded-Host: in some frameworks, any value of X-Forwarded-Scheme other than https makes the application generate a 302 redirect to the HTTPS version of itself — and, if X-Forwarded-Host is also reflected, the Location of that redirect points to https://atacante.exemplo-evil.com/.... The result is any cached page turning into a redirect to the attacker’s domain. (This is exactly the behavior of PortSwigger’s “Web cache poisoning with multiple headers” lab.)

Unkeyed parameters. Poisoning is not only about headers. If the cache excludes certain query params from the key (e.g., it ignores utm_source, lang, or unknown parameters), but the application reflects them, you have the same problem with the URL: ?lang=<payload> that does not enter the key but appears in the HTML.

Fat GET. Some backends read parameters from the body of a GET request, while the cache only keys on the request line. Sending a GET with a body lets you influence the response through a channel the key ignores entirely. (Param Miner detects this case with the fat GET option.)

Cache key normalization / parsing discrepancies. The cache and the origin can disagree about what counts as “the same path.” Differences in how each one handles capitalization, % encoding, double slashes, and delimiters create cache key injection — the attacker poisons a key that the victim will end up hitting.

Chained cache poisoning. An unkeyed input may not be reflected directly, but may alter another response header (e.g., a misconfigured Vary, a Set-Cookie, or an internal redirect) that in turn opens the vector. It is worth testing the whole chain, not just immediate reflection in the body.

And Web Cache Deception

Deception is a variation conceptually distinct enough to deserve its own section — and it is the opposite of poisoning. Here the attacker injects nothing: they exploit a path interpretation discrepancy between the application and the cache to make the cache store an authenticated response that should be private.

The naive rule of many CDNs is: “cache everything ending in .css, .js, .png, .svg — those are static.” The attack consists of building a URL that the application interprets as an authenticated dynamic page, but that the cache interprets as a static file:

GET /account.php/nonexistent.css HTTP/1.1
Host: app.exemplo.com
Cookie: session=<VICTIM's session>

If the application server ignores the /nonexistent.css suffix and serves the account page (/account.php) with the victim’s data, but the CDN looks only at the final extension (.css) and decides to cache it, the result is catastrophic: the victim’s account page — name, email, address, tokens — gets stored in the cache under the URL /account.php/nonexistent.css.

The attack flow:

  1. The attacker sends the authenticated victim the link https://app.exemplo.com/account.php/nonexistent.css.
  2. The (logged-in) victim opens the link. Their request carries the session cookie, and the origin serves their account page. The CDN, seeing the .css extension, decides to cache the response — and, crucially, does not include the Cookie in the cache key. The entry is indexed only by the URL.
  3. The attacker accesses the same URL https://app.exemplo.com/account.php/nonexistent.css while not logged in (no cookie). Since the cache does not key by cookie, they receive the cached copy of the victim’s page.
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=600
X-Cache: hit
Age: 8
...
<h2>Olá, Maria — saldo: R$ 4.231,00</h2>

The parsing discrepancy is the heart of the attack. PortSwigger organizes the variants into four classes, all useful as a testing baseline:

  • Path mapping — the origin maps to a file (/account.php) and ignores the extra segment; the cache treats the whole URL as a static resource. E.g.: /account.php/x.css, /account.php/x.js.
  • Delimiters — a character the origin treats as a delimiter (truncating the path) but the cache does not. The classic one is ; in Java Spring (matrix variables): /account;x.css. The origin reads /account; the cache sees the .css extension.
  • Delimiter decodingencoded delimiters that only one of the two decodes, e.g. %23 (#), %3f (?), %00 (\0): /account%23x.css, /account%3fx.css.
  • Normalization — divergence when resolving .. and slashes: /static/..%2faccount/x.css, double slashes //account/x.css.

Watch out for literal # and ?. The # character does not reach the server: the browser treats it as a fragment and truncates the URL there — which is why it is only useful in its encoded form %23. And a literal ? becomes a query-string separator everywhere, so /account.php?x.css rarely produces the intended discrepancy; it is the encoded form %3f that creates the divergence. Use the encoded versions when building delimiter tests.

Every server + CDN combination reacts differently; the goal is to find one where the origin delivers the private page and the cache decides to store it.

How we exploit it in a pentest

Step 1 — Identify the cache. Send requests in Burp Suite and read the responses looking for X-Cache, cf-cache-status, Age, Via, X-Served-By, X-Varnish. Repeat the same request twice: if the second comes back with hit and a growing Age, there is a cache.

Step 2 — Discover unkeyed inputs. The key tool is the Param Miner extension (Burp/BApp Store), by James Kettle (albinowax). Right-click the request and use “Guess headers” (and also “Guess cookies” / “Guess params”): it injects thousands of candidate headers with a unique canary value and detects which ones affect the response without being part of the cache key — whether by reflecting the canary or by changing the response. It already adds cache busters automatically to isolate your test keys, and it has specific options for poisoning (e.g., fat GET detection and the “twitchy” mode for non-reflected inputs). This is the step that separates guessing from method.

Step 3 — Prove reflection and cacheability. For each candidate header (e.g., X-Forwarded-Host: canario123.com), confirm manually: does the response reflect canario123.com? Is the response cacheable (Cache-Control: public, no Set-Cookie)? Use the Repeater with your own cache buster: send it with the payload, then send it without the header on the same busted path — if the clean version comes back with X-Cache: hit and still contains canario123.com, it is poisoned.

Step 4 — Build the minimal payload. Replace the canary with a controlled, demonstrative value (a domain of yours serving a harmless app.js, or a redirect to a warning page). The goal in a pentest is to prove impact without causing harm: never leave a truly armed payload poisoning the real path in production; demonstrate over an isolated key (cache buster) with a benign resource and document the potential. If you really must touch the real key for evidence, record the TTL window (max-age) and how to revert it (purge/expiration).

Step 5 — Test deception. Take authenticated endpoints that return sensitive data (/account, /profile, /settings) and apply the variants from the previous section: /account/x.css, /account.php/x.js, /account;x.css, and the encoded forms %2f, %23, %3f. For each one, check in Repeater whether the origin still serves the authenticated page and whether the response is cached (X-Cache: hit on the second request). Confirm the leak by opening the same URL in an anonymous session (another browser/private tab, no cookie) and seeing if the victim’s data appears.

Supporting tools. Turbo Intruder to quickly fuzz hundreds of path/extension and header variations with fine control over timing and the cache key. Param Miner for unkeyed headers/cookies/params. Burp’s Comparer to distinguish the poisoned response from the clean one. And always the diagnostic headers as the final arbiter.

Summary for the report

  • Impact: Web Cache Poisoning allows serving malicious JavaScript, phishing redirects, or tampered content to all users of a cached resource (persistent XSS at CDN scale, defacement, DoS). Web Cache Deception exposes authenticated pages — personal data, session/CSRF tokens, financial information — of victims to unauthenticated attackers.
  • Severity: High to Critical. Poisoning with script execution tends toward the High–Critical range (CVSS ~8.0–9.3: affects many users, low complexity), but the final score depends on the scope and the cache window. Deception with PII/token leakage falls in the High–Critical range depending on the sensitivity of the exposed data.
  • Preconditions: Existence of a shared cache (CDN/reverse proxy) in front of the application; an unkeyed input that affects the response (poisoning) or a path interpretation discrepancy between origin and cache that makes an authenticated page look static (deception).
  • Suggested evidence: Poisoning request with the unkeyed header and the payload; response with X-Cache: hit + growing Age showing the reflected payload; for deception, the victim’s authenticated request, the cached response, and the retrieval of the same URL in an anonymous session showing the leaked data. Include the Cache-Control, Age, X-Cache/cf-cache-status headers in each capture.

How to mitigate

The fix attacks the cause: the mismatch between what affects the response and what goes into the cache key. There are two paths for poisoning and one golden rule for deception.

1. Do not reflect input headers without validation

The origin should never build absolute URLs from client-controllable headers. Use a fixed canonical host, set by configuration and not by the request.

# WRONG: trusts a client-controllable header
host = request.headers.get("X-Forwarded-Host") or request.host
canonical = f"https://{host}/pt/sobre"   # <- poisonable

# RIGHT: fixed canonical host, from configuration
CANONICAL_HOST = "loja.exemplo.com"
canonical = f"https://{CANONICAL_HOST}/pt/sobre"

If the application really must trust proxy headers (because it sits behind a load balancer), do it only for trusted proxies, with an explicit allowlist of permitted values for Host/X-Forwarded-Host, rejecting anything else.

2. Include every relevant input in the cache key — or normalize them before the origin

If a header affects the response, it must be in the key. At the CDN level, this is usually configurable. Example in Varnish (VCL), stripping the hostile headers and folding into the key only a header that actually changes the response:

# Varnish VCL: what affects the response must be part of the key (via hash)
sub vcl_recv {
    # Normalize/remove untrusted headers before keying and forwarding
    unset req.http.X-Forwarded-Host;
    unset req.http.X-Host;
    unset req.http.X-Forwarded-Scheme;
}

sub vcl_hash {
    # If some legitimate header varies the response, include it in the key
    hash_data(req.http.Accept-Language);
}

The safest and simplest approach for hostile headers is removal/normalization at the edge: the CDN strips X-Forwarded-Host, X-Host, X-Forwarded-Scheme, and the like before forwarding to the origin, eliminating the injection channel. Only preserve (and key on) the strictly necessary headers.

3. Never cache authenticated or personalized responses

This is the central defense against deception — and an extra layer against poisoning. Any response that depends on the session must be explicitly non-cacheable:

# Every authenticated/personalized response goes out with restrictive directives
@app.after_request
def proteger_cache(resp):
    if usuario_autenticado():
        resp.headers["Cache-Control"] = "no-store, private"
        resp.headers["Vary"] = "Cookie"
    return resp

no-store is the strong directive: it forbids any cache (shared or browser) from storing the response — it is what actually blocks deception. private reinforces that shared caches (CDN/proxy) must not store it, even where no-store is ignored. Vary: Cookie is a safety net: if something slips through and gets cached, different sessions will not collide on the same entry. Configure the CDN to respect these directives — many have overrides that ignore the origin and need to be adjusted.

4. Normalize paths and be explicit about what is cacheable

Deception dies when the CDN rule stops being “cache by extension” and becomes “cache only known static paths.” Instead of a blocklist of dynamic extensions, use an allowlist of static prefixes:

# Nginx: cache ONLY the known static directory; everything else, never.
location /static/ {
    proxy_cache cdn_cache;
    proxy_cache_valid 200 10m;
    proxy_pass http://app_backend;
}

location / {
    proxy_cache off;          # dynamic/authenticated is never cached
    proxy_pass http://app_backend;
}

In addition, make the origin and cache agree on the path: reject requests with suspicious suffixes at the origin (/account.php/anything.css should return 404, not serve the account), and normalize %2f, double slashes, ;, and encoded delimiters identically in both layers. The parsing discrepancy is the fuel — eliminate it.

Mitigation checklist

  • Never reflect Host/X-Forwarded-Host/X-Forwarded-Scheme and the like in the response; use a fixed canonical host from configuration.
  • Remove or normalize untrusted headers at the edge (CDN) before forwarding to the origin.
  • Ensure that every input that affects the response enters the cache key (or is eliminated before the origin).
  • Authenticated/personalized responses with Cache-Control: no-store, private and Vary: Cookie — and the CDN configured to respect that.
  • CDN caching by allowlist of static paths, not by file extension.
  • Origin and cache with identical path parsing; reject static suffixes on dynamic routes (/account.php/x.css404) and normalize encoded delimiters (%2f, %23, %3f).
  • Do not allow GET to read parameters from the body (eliminate fat GET).
  • Monitor X-Cache, Age, and Cache-Control in production; alert on authenticated pages served with hit.

Cache poisoning and cache deception are two faces of the same confusion: the cache and the application disagree about what counts as “the same response.” One poisons the entry to serve the same poison to everyone; the other tricks the output to steal the secret of one. The defense, in both, is to make what decides the response and what decides the key be the same thing — and never, ever, let a private response rest in a cache that anyone can read.