| Platform | Type | Difficulty | Objective |
|---|---|---|---|
| PortSwigger Web Security Academy | Web Cache Deception | Practitioner | Exploit a normalization discrepancy between the origin server and cache to store carlos's /my-account response under a /resources/ path that triggers the static directory cache rule |
Exploiting origin server normalization for web cache deception¶
Logged in as wiener:peter and checked /my-account first — no cache headers, dynamic as expected.
Confirmed static resources under /resources/ do get cached with max-age=30, so this time the rule to abuse is a directory prefix rather than a file extension.
Tried the tricks from the previous two labs first out of habit. Path mapping (/my-account/teto.js) came back 404, and fuzzing delimiters didn't produce anything useful either — this origin doesn't share those particular quirks.
Switched to path traversal from inside /my-account:
GET /my-account/../ HTTP/2
That just redirected to root, nothing cached. Tried resolving back to the same endpoint:
GET /my-account/../my-account/ HTTP/2
Got the account page back, but still not cached. The issue was obvious once I thought about it — traversing from inside /my-account never actually touches the /resources/ prefix, so there's no static directory rule to trigger in the first place. Needed to flip the approach: start the path inside /resources/ and traverse back up to /my-account instead of the other way around.
GET /resources/js/../../my-account HTTP/2
HTTP/2 200 OK
Cache-Control: max-age=30
Age: 10
(my-account body)
That worked. The path starts with /resources/js/, so it matches the static directory cache rule right away, and the origin server resolves the ../../ segments and ends up serving /my-account underneath. The catch was that this exact URL would collide across victims — I needed something that would give each victim's cached response a unique key.
First attempt at building the exploit used literal slashes in the traversal:
<script>
document.location="https://.../resources/js/../../my-account?miku"
</script>
Delivered it to the victim, but never got a cached response back. Turned out the victim's own browser resolves ../../ before the request ever leaves it — by the time the URL reaches the cache, it's already been normalized down to /my-account?miku, so the cache never even sees the /resources/ prefix and the static directory rule never fires. The traversal I'd just proven works over Burp gets neutralized by the browser itself when delivered as a real exploit.
Fixed that by URL-encoding the slashes so the browser can't resolve them ahead of time:
/resources/js/..%2f..%2fmy-account
%2f doesn't get decoded by the browser, so it forwards the path exactly as written to the cache. The cache sees /resources/js/..%2f..%2fmy-account, which still starts with /resources/ and matches the rule, while the origin server decodes %2f back into a real slash and resolves the dot-segments on its end, landing on /my-account. That's the actual normalization discrepancy driving this lab: the origin decodes and resolves the traversal, the cache does neither.
Rebuilt the exploit with the encoded slashes and a cache buster to keep it unique per delivery:
<script>
document.location="https://0aa2006604f11fad8385463c007a004d.web-security-academy.net/resources/js/..%2f..%2fmy-account?miku"
</script>
Delivered it to the victim. Carlos's browser sent GET /resources/js/..%2f..%2fmy-account?miku straight to the cache without touching the encoded slashes, the cache matched it against the /resources/ rule and stored it, while the origin decoded the path down to /my-account and returned carlos's actual account data.
Requested the same URL myself right after:
Carlos's account page, API key included, sitting right there in the cached response. Submitted it to wrap up.
The encoding piece is really what made this one click — a raw traversal sequence gets cleaned up by the victim's own browser before it ever becomes an attack, so the exploit only works once the delimiter characters survive the trip from browser to cache untouched.
Lab solved 2 more to goooo!!!