| Platform | Type | Difficulty | Objective |
|---|---|---|---|
| PortSwigger Web Security Academy | Web Cache Deception | Practitioner | Exploit a normalization discrepancy where the cache resolves encoded dot-segments but the origin doesn't, combined with a # delimiter the origin uses but the cache doesn't, to cache carlos's /my-account response under a /resources/ path |
Exploiting cache server normalization for web cache deception¶
Logged in as wiener:peter and checked /my-account first — no cache headers, dynamic as always.
Confirmed /resources/ is cached, same static directory rule as the previous lab.
Started by looking for a delimiter the origin actually uses, since that's the piece that would let me truncate the path back down to /my-account regardless of what I append after it. Sent GET /my-account%payload%teto through Intruder with the provided delimiter list, payload encoding disabled so the characters hit the server raw.
A handful of characters came back 200 OK with the account page: #, ?, %23, %3f. All delimiters as far as the origin is concerned — it truncates the path at # or ? and serves /my-account no matter what trails behind them.
Next question was how to actually reach /resources/ from /my-account, since none of the earlier labs' direct traversal worked here either:
GET /my-account/../../my-account HTTP/2
→ 404 Not Found
That's where the # delimiter became the actual key. If the origin truncates at #, a path like /my-account#/../resources would have the origin only ever seeing /my-account, treating everything past # as a fragment it never even processes — but a cache that doesn't recognize # as a delimiter at all would keep reading the full string, including the traversal sequence, and could resolve it down to /resources.
GET /my-account#/../resources HTTP/2
HTTP/2 200 OK
Cache-Control: max-age=30
X-Cache: miss
Cached, and carrying the account page. Added a .js filename on the end to also line up with the static extension rule as a belt-and-suspenders check:
GET /my-account#/../resources/teto.js HTTP/2
Also cached, confirming the discrepancy was solid. What's actually happening is two completely different interpretations of the same string. The cache doesn't treat # as a delimiter at all, so it reads the entire path /my-account#/../resources/miku.js, resolves the dot-segment traversal in there, and lands on /resources/miku.js — a direct match for the /resources/ cache rule, so it stores the response. The origin, meanwhile, does treat # as a delimiter, so everything from # onward is just a fragment it discards, leaving it with /my-account and a dynamic account page to return. The cache ends up storing dynamic, sensitive content because it thinks it's looking at a static /resources/ file.
One more wrinkle before this could actually work against a real victim: the exploit URL needs the slash after # encoded, #%2f..%2fresources, rather than a literal slash. A victim's browser won't touch %2f since it doesn't read it as a path separator, so the whole string reaches the cache intact. A literal #/../ would get resolved by the browser itself before the request ever left it, same problem as the previous lab.
Built the exploit with the encoded traversal and a unique cache buster:
<script>
document.location="https://0a17005e036afd6580236c16005a00fb.web-security-academy.net/my-account#%2f..%2fresources/miku.js"
</script>
Delivered it to the victim. Carlos's browser sent GET /my-account#%2f..%2fresources/miku.js straight to the cache with the encoding untouched, the cache resolved the traversal to /resources/miku.js and stored it, while the origin only ever processed /my-account and returned carlos's own account data.
Requested the same URL myself right after:
GET /my-account#/../resources/miku.js HTTP/2
Carlos's account page came back straight from cache.
Submitted his API key. What made this one harder than the last two is that no single discrepancy was enough on its own — the traversal-resolution mismatch got the cache to compute a /resources/ path, but without the # delimiter the origin would have choked on the same traversal instead of cleanly truncating down to /my-account. It took stacking both quirks together to get a payload the origin serves safely and the cache stores dangerously.
Lab solved ONLY ONE MORE OMG!