Web Cache Poisoning: Testing for Cache-Based Attacks
Web cache poisoning turns a performance feature into an attack vector. Instead of exploiting the victim directly, the attacker poisons a cache entry so that every user who requests the same resource receives a malicious response. A single crafted request can serve XSS payloads, redirect all visitors, or leak sensitive data to thousands of users — all without any further interaction from the attacker once the poison lands in cache.
Web cache poisoning went from theoretical curiosity to high-impact attack class after James Kettle's 2018 research demonstrated practical exploitation against major websites. Today it's a first-class vulnerability in any web security testing engagement. This guide covers the methodology: how caches work, what makes inputs unkeyed, and how to systematically find and exploit poisoning vectors.
How Web Caches Work
A web cache sits between clients and the origin server. When a request arrives, the cache checks whether it has a stored response for that request. If it does (a cache hit), it serves the stored response without forwarding the request to origin. If it doesn't (a cache miss), it forwards the request to origin, stores the response, and serves it to the client.
The cache key is what the cache uses to identify a unique request. By default, the cache key typically includes:
- The request URL (scheme, host, path)
- The query string
Notably absent from the default cache key: most HTTP headers, cookies, and POST body parameters. This is intentional — caching is designed for anonymous, stateless content. But web applications frequently use headers to customize responses, and if those headers aren't included in the cache key, they become unkeyed inputs — values that influence the response but don't differentiate cache entries.
The Poisoning Primitive
Cache poisoning exploits the gap between what the cache uses to store/retrieve responses (the cache key) and what the application uses to generate responses (all request inputs). The attack flow:
- Identify an unkeyed input that influences the response
- Craft a request with a malicious value for that input
- Trigger a cache miss so the origin processes the request and the cache stores the response
- The poisoned response is now served to all subsequent users requesting the same cache key
The impact depends on what the unkeyed input controls. If it's reflected into a JavaScript import, you get XSS for every visitor. If it's reflected into a redirect location, you get mass redirection. If it's used to construct a resource URL, you can replace legitimate resources with attacker-controlled content.
Common Unkeyed Inputs
Host Header and X-Forwarded-Host
Many applications use the Host header to construct absolute URLs — for JavaScript imports, canonical links, API endpoints, and redirect targets. CDNs and reverse proxies commonly strip the original Host and replace it, but they often forward the original via X-Forwarded-Host. If the application trusts X-Forwarded-Host and the cache doesn't include it in the cache key:
GET / HTTP/1.1
Host: example.com
X-Forwarded-Host: attacker.com
HTTP/1.1 200 OK
Cache-Control: public, max-age=3600
...
<script src="https://attacker.com/static/app.js"></script>
If this response is cached keyed only on example.com/, every visitor to the homepage loads JavaScript from the attacker's server. This is a drive-by compromise at CDN scale.
X-Forwarded-Scheme and X-Forwarded-Proto
Applications that redirect HTTP to HTTPS often check X-Forwarded-Scheme or X-Forwarded-Proto. If the application trusts these headers for redirect logic and they're unkeyed:
GET / HTTP/1.1
Host: example.com
X-Forwarded-Scheme: http
HTTP/1.1 301 Moved Permanently
Location: http://example.com/
Poisoning the homepage with a redirect to HTTP downgrades all visitors, enabling MITM attacks on the subsequent HTTP request.
X-Original-URL and X-Rewrite-URL
Some frameworks and proxies support URL override headers — X-Original-URL, X-Rewrite-URL, X-HTTP-Method-Override. These override the request path or method at the application layer. If the cache keys on the original URL but the application responds to the overridden URL, the cache serves the overridden response to all users requesting the original URL.
Unkeyed Query Parameters
Caches sometimes deliberately exclude certain query parameters from the cache key to improve cache hit rates. UTM parameters (utm_source, utm_campaign), analytics parameters, and session tokens are common exclusions. If the application reflects any of these parameters in the response without encoding them, they become unkeyed XSS vectors:
GET /page?utm_source=<script>alert(1)</script> HTTP/1.1
HTTP/1.1 200 OK
...
You arrived from: <script>alert(1)</script>
Because utm_source is excluded from the cache key, this response is stored and served to users requesting /page without the parameter.
Fat GET Requests
Some servers and frameworks process the body of GET requests. If the body is not included in the cache key but is processed by the application, body parameters become unkeyed inputs. This is the "fat GET" attack:
GET /search HTTP/1.1
Host: example.com
Content-Length: 20
q=<script>alert(1)</script>
The cache key is example.com/search with no query string. The body parameter q influences the response. If the application reflects q without encoding, the poisoned response is stored against the clean URL.
Cache Deception vs Cache Poisoning
These are distinct but related attacks. Cache poisoning makes the cache store a response that contains attacker-supplied content. Cache deception makes the cache store a response that contains victim-specific data — then the attacker retrieves it.
The deception attack exploits path confusion. If a cache caches responses for /account/dashboard.css but the application returns the dashboard HTML regardless of extension (treating anything under /account/ as the dashboard), the attacker tricks the victim into requesting /account/dashboard.css, the cache stores the authenticated dashboard HTML, and the attacker fetches it later as an unauthenticated user.
The exploitability depends on the cache's path-matching behavior. CDN path normalization and the application's routing logic are both factors.
Testing Methodology
Step 1: Understand the Caching Behavior
Before hunting for unkeyed inputs, confirm that caching is active. Look for cache-related headers in responses:
| Header | What it tells you |
|---|---|
X-Cache: HIT |
Response served from cache |
X-Cache: MISS |
Response from origin, now cached |
Cache-Control: public, max-age=3600 |
Response is cacheable for 1 hour |
Age: 243 |
Response has been in cache for 243 seconds |
Vary: Accept-Encoding |
Cache key includes Accept-Encoding |
CF-Cache-Status |
Cloudflare-specific cache status |
A reliable way to confirm caching: send the same request twice. If the second response has X-Cache: HIT or an Age header greater than zero, the response is being cached. If the second request is also a MISS, the cache may be keying on something in your request (like a session cookie) — try with no cookies.
Step 2: Enumerate Unkeyed Inputs with Param Miner
Param Miner is a Burp Suite extension specifically designed for cache poisoning discovery. It automates the discovery of unkeyed headers and query parameters by sending requests with a large number of candidates and analyzing which ones influence the response without appearing in the cache key.
Key Param Miner functions:
- Guess headers — sends requests with hundreds of common headers, watching for reflected values or behavioral changes
- Guess params — enumerates query parameters that influence the response but aren't in the cache key
- Identify cache oracles — finds response differences that reveal whether the cache key changed
Param Miner adds a cache buster parameter (cb=random) to each test request by default to prevent accidentally poisoning the real cache during discovery. Always verify this is enabled before running against production targets.
Step 3: Confirm Unkeyed Behavior Manually
When Param Miner identifies a candidate, confirm manually:
# Request 1: inject a detectable canary value
GET /?cb=unique123 HTTP/1.1
Host: example.com
X-Forwarded-Host: canary-value.com
# If reflected in response, confirm it's unkeyed:
# Request 2: same URL, different or no X-Forwarded-Host
GET /?cb=unique123 HTTP/1.1
Host: example.com
# If the canary value still appears, X-Forwarded-Host is unkeyed
The cache buster (cb=unique123) ensures you're testing a fresh cache entry each time. Change it for each test to avoid receiving a cached version of a previous test.
Step 4: Identify Exploitable Reflection Points
Not every unkeyed input is exploitable. The value must be reflected in a way that creates impact:
- HTML context — reflected without encoding → XSS
- JavaScript string context — reflected in a JS variable → XSS
- Script src attribute — reflected in a resource URL → script injection
- Location header — reflected in redirect → open redirect or downgrade
- Link/canonical header — less impact, but SEO manipulation
Test the reflection with a benign payload first — a distinctive string — before escalating to XSS. Confirm the reflected value appears in the cached response before attempting any exploit payload.
Step 5: Poison the Cache
Once you've confirmed an exploitable unkeyed input, trigger a cache miss with the poisoned value. To force a cache miss without a cache buster, you can:
- Wait for the current cache entry to expire
- Use a
Cache-Control: no-cacherequest header (some caches respect this to revalidate) - Add a parameter that's unkeyed by the CDN but recognized as a cache buster by the cache layer
GET / HTTP/1.1
Host: example.com
X-Forwarded-Host: attacker.com
# If the origin responds with a 200 that reflects attacker.com,
# and X-Cache shows MISS on this request,
# subsequent requests to GET / will return the poisoned response.
CDN-Specific Behaviors
Cloudflare
Cloudflare's cache behavior is heavily configurable but defaults include: static file extensions are cached by default, dynamic content requires explicit cache rules. CF-Cache-Status header reveals cache state. Cloudflare adds CF-Connecting-IP for the real client IP — if the application uses this to customize content and Cloudflare doesn't include it in the cache key, it's an unkeyed vector in the presence of CF.
Akamai
Akamai passes several proprietary forwarding headers. Check for True-Client-IP, Akamai-Origin-Hop, and X-headers injected by Akamai's edge servers. If the origin application trusts and reflects these, they're poisoning candidates depending on Akamai's cache key configuration.
Varnish
Varnish's cache key is fully configurable in VCL. Common misconfigurations: excluding headers from the cache key without stripping them from forwarded requests, inconsistent cache key normalization between clusters, and custom vary implementations that don't match the application's actual behavior.
Cache Key Normalization Attacks
Different layers in the request pipeline may normalize URLs differently. If the CDN normalizes /api/v1/user%2Fadmin to /api/v1/user/admin for cache key purposes, but the application sees and processes the original encoded form, they disagree on what the request is. An attacker can send:
GET /api/v1/user%2Fadmin HTTP/1.1
The CDN caches this as /api/v1/user/admin. If the application's response differs based on whether the slash is encoded, the normalization mismatch creates a poisoning surface.
Similarly, some caches normalize the port in the Host header (example.com:443 → example.com) while the application treats them differently. Test with non-standard port values in the Host header to probe normalization behavior.
Detecting Cache Poisoning in Production
Cache poisoning that's occurred in production is difficult to detect without active monitoring. Indicators to watch for:
- Unexpected script sources appearing in CSP violation reports
- Subresource Integrity (SRI) failures logged in the browser
- Unusual redirect chains in access logs
- Cache entries returning content that differs from what the origin would serve
- User reports of unexpected behavior that clears after a hard refresh (bypassing cache)
The most reliable detection is active: periodically fetching cached responses and comparing them against expected content. If your CDN provides log access, anomalous X-Forwarded-Host or other forwarding header values in request logs are strong indicators of poisoning attempts.
Remediation
Validate and Strip Unkeyed Headers at the Edge
The cleanest fix: configure your CDN or reverse proxy to strip headers that the application shouldn't trust before forwarding requests to origin. If the application doesn't need X-Forwarded-Host, remove it entirely at the edge. Headers that reach the origin are headers the application might trust — eliminate them if they're not needed.
Include Security-Sensitive Headers in the Cache Key
If the application legitimately uses a header to customize responses, include that header in the cache key. In Varnish, this means adding it to hash_data(). In CloudFront, it means listing it in the cache policy's Headers settings. This prevents the response from being shared across requests with different header values, eliminating the poisoning primitive at the cost of cache efficiency.
# Varnish VCL: include X-Forwarded-Host in cache key
sub vcl_hash {
hash_data(req.url);
if (req.http.X-Forwarded-Host) {
hash_data(req.http.X-Forwarded-Host);
} else {
hash_data(req.http.Host);
}
return(lookup);
}
Never Trust Forwarding Headers for Security Decisions
The application layer should not use X-Forwarded-Host, X-Forwarded-Proto, or similar proxy headers to make security decisions — URL construction, access control, or redirect targets — unless those headers are set exclusively by trusted infrastructure you control. If your application constructs resource URLs from X-Forwarded-Host, an attacker who can insert that header (through a misconfigured proxy, or by bypassing your CDN entirely) can control where your resources are loaded from.
Use Relative URLs
Where possible, construct resource URLs as relative rather than absolute paths. A script tag with src="/static/app.js" is immune to host header injection; one with src="https://example.com/static/app.js" that constructs the host from a request header is not. Prefer relative references for same-origin resources throughout the application.
Ironimo automatically tests for web cache poisoning vectors — unkeyed headers, fat GET requests, cache key normalization mismatches, and CDN-specific forwarding header abuse — as part of every scan.