Skip to content
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.

Screenshot
Screenshot

Confirmed /resources/ is cached, same static directory rule as the previous lab.

Screenshot

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.

Screenshot

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
Screenshot

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
Screenshot
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>
Screenshot

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
Screenshot

Carlos's account page came back straight from cache.

Screenshot

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!

Resources