This is the second lab in the series. The first one abused how the URL maps to a resource; this one abuses how a single character is interpreted. If you have not read it, start with Path Mapping Discrepancies — the attack shape is the same, only the discrepancy changes.
Lab context
- Platform: PortSwigger Web Security Academy
- Lab: Exploiting path delimiters for web cache deception
- Goal: read the API key belonging to
carlos - Given credentials:
wiener:peter
As before, this is my own reproduction, not the official solution.
Background: delimiters are not universal
A delimiter is a character that marks a boundary in a URL. The familiar one is ?, splitting the path from the query string. The trap is that different stacks disagree on which characters count.
Some examples of framework-specific delimiters:
- Java Spring uses
;for matrix variables, so/profile;foo.cssis/profileto Spring. - Ruby on Rails uses
.for the response format, so/profile.icofalls back to the HTML view and returns profile data. - OpenLiteSpeed uses an encoded null
%00, so/profile%00foo.jsis/profile.
In every case the origin cuts the path at a character the cache in front of it does not recognise. The cache reads the full string, sees a static extension on the end, and caches a response that was never meant to be shared. The pattern is always:
/sensitive-endpoint[DELIMITER]anything.static-ext
origin: cuts at [DELIMITER] -> serves sensitive data
cache: does not know [DELIMITER] -> sees .static-ext -> caches it
Recon
Log in as wiener:peter, open /my-account, confirm the response carries the API key. That is the target.
Hypothesis
If the origin recognises some delimiter that the cache does not, I can build a URL that is a sensitive page to one and a static file to the other. I do not know which character yet — so first I need the origin’s delimiter set, then I need to find which of those the cache ignores.
PoC
Establish a baseline. How does the origin treat a path it does not know?
GET /my-account/abc HTTP/2
Returns 404. Good — unlike the previous lab, this origin does not fold a trailing segment back to /my-account. And:
GET /my-accountabc HTTP/2
Also 404. This is my reference: any character I insert between /my-account and abc that flips the response to 200 is a delimiter the origin honours.

Brute-force the delimiter. Send /my-accountabc to Intruder and mark a payload position between the two:
GET /my-account§§abc HTTP/2
PortSwigger publishes a delimiter character list for exactly this — roughly 30 special ASCII characters. Load it as a simple list.
One thing that will waste your time if you miss it: turn off “URL-encode these characters” in the payload settings. Leave it on and Burp sends %3B instead of ;, the origin never sees the raw delimiter, and every result comes back 404.

Sort the results by status code:
| Character | Status | Meaning |
|---|---|---|
; | 200 | delimiter — origin cuts here |
? | 200 | delimiter — origin cuts here |
| others | 404 | not a delimiter |

So the origin honours ; and ?. Now I need one the cache does not honour.
Find the discrepancy. Test ? first:
GET /my-account?abc.js HTTP/2
No X-Cache header worth having — the cache also treats ? as a delimiter, so it sees /my-account, not a .js file, and never caches. Dead end. Almost everything treats ? as a query separator, so this is the expected result.
Test ;:
GET /my-account;abc.js HTTP/2
- First send:
X-Cache: miss. The cache does not treat;as a delimiter — it reads the whole path, sees.js, and stores the response. - Second send:
X-Cache: hit. Confirmed cached.

That is the discrepancy:
/my-account;abc.js
origin: cuts at ";" -> /my-account -> returns profile data
cache: reads it all -> /my-account;abc.js -> ends in .js -> CACHE
Turn it on the victim. Exploit server body:
<script>document.location="https://LAB-ID.web-security-academy.net/my-account;wcd.js"</script>
Fresh segment (wcd) again, so the cache builds a new entry instead of returning my own wiener response. Deliver to victim. When carlos loads it, the origin serves his account page and the cache stores it under /my-account;wcd.js.
Request that URL myself:
https://LAB-ID.web-security-academy.net/my-account;wcd.js
Cache hands back carlos’s API key. Submit, solved.

Root cause
Three things line up:
- The origin and the cache run different software with different ideas about which characters delimit a path.
- The cache decides what to store from the extension, not from the response
Content-Type. - Nothing normalises the URL or validates the response before it lands in the cache.
Any one of those removed and the attack fails. Together they let a per-user page be stored as a public static file.
Fix
For the cache:
- Respect
Cache-Controlinstead of inferring “static” from the extension. - Check the response
Content-Type— do not cachetext/htmlunder a.jsURL. - Normalise the path before building the cache key, stripping the delimiters the origin might act on.
For the origin:
- Send
Cache-Control: no-storeon any endpoint returning sensitive data. - Reject paths with unexpected delimiters rather than falling back to a valid route.
For developers: know your framework’s delimiter behaviour (; in Spring, . in Rails, and so on) and test how a path is parsed at every layer — CDN, reverse proxy, application — not just the one you wrote.
FAQ
Why does ? not work when the origin accepts it?
Because the cache accepts it too. For this attack you need a character only the origin treats as a delimiter. When both sides cut the path at the same place, there is no discrepancy to abuse.
Do I need Burp Pro for the Intruder step? No. Community throttles Intruder, but the delimiter list is about 30 characters, so it finishes in seconds either way.
How is this different from the path mapping lab? There the discrepancy was how the URL maps to a resource — a trailing segment the origin dropped and the cache kept. Here it is how one character is interpreted. Same outcome, different parsing mismatch. The two together are why I kept them as a series.