Web Cache Poisoning: How One Request Can Attack Thousands
A caching layer is supposed to make a site faster. Handled carelessly, it can turn a single malicious request into an attack delivered to every visitor.
What is web cache poisoning?
Web cache poisoning is an attack where an attacker gets a harmful HTTP response stored in a cache, so that the cache then serves that poisoned response to all the normal users of the site.
The key idea: it is not a standalone attack by itself. Think of it as a delivery mechanism. On its own, a reflected bug might only affect the one person who triggers it. But once it is baked into the cache, it gets handed out to everyone who loads the page — turning a low-impact bug into a mass attack with zero interaction from the victims.
First, how does a cache actually work?
A cache sits between the user and the back-end server. When a response is generated once, the cache stores a copy. The next time an "equivalent" request arrives, the cache just replays the stored copy instead of bothering the server. Faster for users, lighter load on the server.
Cache keys: keyed vs unkeyed
Here is the part that makes the whole attack possible. To decide whether two requests are "the same", the cache does not compare the entire request. It only looks at a few chosen parts — together these are called the cache key (typically the request line and the Host header).
- KEYED Parts inside the cache key. The cache compares these. Example: URL, query string, Host.
- UNKEYED Everything else — the cache ignores these when matching. Example: many custom headers and cookies.
A problem appears when an input is unkeyed (the cache ignores it) but the back-end still uses it to build the response. The cache thinks the request is identical to a normal one, so it stores the attacker's response under the normal URL.
A concrete example
Suppose a site builds the URL of a script it loads from the X-Forwarded-Host header (a header normally added by proxies to say which host the user originally asked for). The attacker sends:
GET / HTTP/1.1
Host: innocent-site.com
X-Forwarded-Host: evil.com ← unkeyed, but the server trusts it
The server reflects that value into the page:
<script src="https://evil.com/resources/js/analytics.js"></script>
The cache ignored X-Forwarded-Host because it is unkeyed, so this poisoned page gets cached against the normal home page. The attacker hosts a malicious analytics.js on evil.com. Now every visitor who opens the home page runs the attacker's JavaScript — no clicking required. That is effectively mass stored XSS.
How an attacker exploits it
The process is methodical, usually three steps:
- Find unkeyed inputs. Add unusual headers to a request and watch whether the response changes. This is automated with tooling that brute-forces a large list of headers to spot which ones affect the response while still being ignored by the cache.
- Turn it into a harmful response. Check how that input is used. If it is reflected into the page unsanitized, or used to build a URL or redirect, it becomes an injection point for XSS, an open redirect, and so on.
- Get it cached. Make the response cacheable and confirm it actually landed in the cache — usually by checking response headers like
X-Cache: hitorAge. Once the poisoned copy is stored, every matching visitor receives it.
While probing a live site, always add a cache buster (a unique query parameter) to each request. This keeps your test responses tied only to you, so you don't accidentally serve a poisoned response to real users.
Why these headers show up so often
Two headers appear in most real-world cases: X-Forwarded-Host and X-Forwarded-Scheme (or X-Forwarded-Proto). They are non-standard headers added by proxies and CDNs. They are dangerous because they usually satisfy both attack conditions at once:
| Header | Status in cache | Used by back-end for |
|---|---|---|
X-Forwarded-Host | UNKEYED | Building absolute URLs, script sources, canonical links, redirect targets |
X-Forwarded-Scheme | UNKEYED | Deciding HTTP vs HTTPS — often triggers a redirect |
They are frequently paired: one header triggers a redirect, the other controls where that redirect points — a clean open-redirect-to-phishing primitive.
What's the impact?
The severity depends on two things: how harmful the cached payload is, and how much traffic the page receives. Poison a high-traffic home page and a single request can affect thousands of users. Common outcomes:
- Mass XSS — session theft, account takeover, defacement, served to every visitor.
- Malicious resource import — the page loads attacker-controlled JavaScript or CSS.
- Open redirect / phishing — users sent to an attacker page from a trusted-looking URL.
- Denial of service — cache an error page so legitimate users just see a broken site.
- Persistence — even after the cache entry expires, the attack can be scripted to re-poison indefinitely.
How to prevent it
- Only cache truly static content. Never cache dynamic or reflected responses. And be strict about what "static" means — confirm an attacker can't trick the back-end into serving a malicious version of a static resource.
- Disable headers you don't need. Many attacks exist only because a CDN or framework supports obscure headers like
X-Forwarded-Hostby default. If the site doesn't need it, turn it off — an input that doesn't exist can't be injected. - Make impactful inputs keyed. The golden rule: if an input changes the response, it must be part of the cache key. If you must exclude it for performance, rewrite/normalize the request instead of silently ignoring it.
- Reject fat GET requests (GET requests carrying a body), which some stacks allow by default.
- Set caching headers correctly. Use
Cache-Control: no-storeorprivatefor sensitive responses, and a correctVaryheader so the cache distinguishes requests that genuinely differ. - Patch client-side bugs anyway. A DOM or XSS issue that looks unexploitable alone can become exploitable through a cache quirk.
Web cache poisoning happens at the gap between what the cache ignores and what the server trusts. Close that gap — either stop the server trusting the input, or make the cache stop ignoring it.
Comments
Post a Comment