writeups

Web Cache Deception via Path Mapping Discrepancies

5 min read by Mai Tan Loi
Contents

This is the first of two web cache deception labs I worked through. The second one abuses a different discrepancy — the delimiter, not the mapping — and it is the next post in the series.

Lab context

  • Platform: PortSwigger Web Security Academy
  • Lab: Using path mapping discrepancies
  • Goal: read the API key belonging to carlos
  • Given credentials: wiener:peter

Everything below is my own reproduction. I am not reprinting the official solution — if you want that, it is on the lab page.

Background: two ways to read a path

A server has to turn a URL path into something to serve. Two common styles:

Traditional mapping points the path at a file on disk:

/static/css/main.css  ->  /var/www/static/css/main.css

REST-style mapping treats the path as a route, and the trailing segments as parameters the application interprets rather than files:

/user/123/profile  ->  handler(userId=123, view="profile")

In the REST style, a trailing segment does not have to correspond to a file. That is the assumption the cache does not share.

Recon

I log in as wiener:peter and open My Account. The page shows an API key — that is the sensitive data the lab wants me to steal from carlos.

In Burp, I find GET /my-account in the proxy history and send it to Repeater.

Hypothesis

If the origin is REST-style, it might route /my-account/anything back to /my-account and ignore the extra segment. If the cache is traditional, it might look at that same URL, see a static-looking extension, and cache the response. If both are true at once, I can get an authenticated response cached under a URL that does not require authentication.

Two things to prove: the origin ignores the extra segment, and the cache stores the result.

PoC

Does the origin ignore a trailing segment? I append an arbitrary one:

GET /my-account/abc HTTP/2
Host: LAB-ID.web-security-academy.net
Cookie: session=...

Response: 200 OK, and it still contains the account page with the API key. So the origin routes /my-account/abc to /my-account and drops /abc. First half confirmed.

Does the cache store it? I swap the trailing segment for one that ends in a static extension:

GET /my-account/abc.js HTTP/2
Host: LAB-ID.web-security-academy.net
Cookie: session=...

First send:

HTTP/2 200 OK
Cache-Control: max-age=30
Age: 0
X-Cache: miss

X-Cache: miss — not served from cache yet. max-age=30 — if it does get cached, it lives for 30 seconds.

Second send, within that window:

HTTP/2 200 OK
Cache-Control: max-age=30
Age: 8
X-Cache: hit

X-Cache: hit. The cache is now serving my account page — API key and all — as if it were a static .js file. Second half confirmed.

Repeater showing X-Cache hit on the cached account response

Turning it on the victim. I go to the exploit server and deliver a redirect:

<script>document.location="https://LAB-ID.web-security-academy.net/my-account/xyz.js"</script>

I use a fresh segment (xyz.js) so the cache builds a new entry instead of handing me back the one from my own testing. When carlos loads the link, his browser sends the request with his session, the origin returns his account page, and the cache stores it under /my-account/xyz.js.

Within the 30-second window I request the same URL in a browser with no session at all:

https://LAB-ID.web-security-academy.net/my-account/xyz.js

The cache serves carlos’s response, API key included. Submit it, lab solved.

Cached victim response returned without authentication The recovered API key

Root cause

The origin and the cache parse the same URL under different rules:

  • The origin is REST-style: /my-account/xyz.js routes to /my-account, the trailing segment is noise.
  • The cache is traditional: /my-account/xyz.js ends in .js, so it is a static asset worth caching.

Neither is wrong on its own. The vulnerability is that they run in the same request path and never reconcile their view of what the URL means. A response that should be private, per-user, and uncacheable gets stored under a key any anonymous user can request.

Fix

  • Cache on Content-Type, not on the extension. A response that is text/html should not be cached because the URL happens to end in .js.
  • Make the origin reject the extra segment. If /my-account/xyz.js is not a real route, return 404 instead of silently falling back to /my-account.
  • Mark sensitive responses uncacheable. Cache-Control: no-store on any endpoint that returns per-user data closes the door regardless of how the URL is parsed.
  • Include enough in the cache key that a user-specific response cannot be served to a different user.

Any one of these breaks the attack. The point is that the cache and the origin have to agree on what is cacheable, and right now they do not.

FAQ

Why does the trailing .js matter if the origin ignores it? The origin ignores it, but the cache does not. The extension is the only reason the cache decides to store the response. Remove it and the origin still serves the page, but the cache leaves it alone.

Why a fresh path segment in the exploit? The cache keys on the full path. Reusing the segment from my own testing risks serving me my cached page instead of caching the victim’s. A new segment forces a clean entry tied to the victim’s request.

Does this need Burp Pro? No. Proxy and Repeater in the Community edition are enough for this lab.

Mai Tan Loi

Mai Tan Loi — #maitanloi

More about me →