Archonyx is a web challenge from Cyber Apocalypse CTF 2026. It shipped as full server-side source rather than a black-box instance, a Node/Express app plus a headless-Chromium report bot, so the first move was reading code. The goal is /readflag, a setuid-root binary that prints /flag.txt.
The finished chain runs five phases, all the way to root:
- Leak the bot's relay key with a CSS-only side channel that gets around the page's Content-Security-Policy, read back through
window.length. - Read
/app/.envthrough a symlink planted viadecompress, dereferenced by the app's own file reader. - Forge a
ledgermasterJWT using the secret from step 2, against a verifier that only checks the signature. - Plant a JavaScript file at a known public path by exploiting a mismatch between the zip validator and the zip extractor.
- Run it through Less's
@plugindirective, which loads the file as a Node module.
None of this was obvious on the first read. What follows is how it actually came together, including the parts that didn't work.
Mapping the attack surface
Reading through the route definitions gave a quick picture of what needs what:
| Route group | Gate |
|---|---|
/api/* |
resolveAuth: session cookie, or x-api-key belonging to a verified user |
/ledgermaster/* |
requireRole('ledgermaster'), which reads the role straight out of the JWT |
/broker/me, /widget/*, /file, /consignments |
requireSession |
POST /report, GET /api/convoys, POST /api/convoys, GET /api/feed/:file.json |
nothing (our entry point) |
Registration works, but login refuses unverified accounts, and only a ledgermaster can verify anyone. So an account can be created and never used, which ruled out the obvious "just register and go" path early.
The interesting sink is POST /ledgermaster/render, which compiles attacker-supplied Less. Less's @plugin directive loads its target as a Node module and runs it, which is code execution the moment I can reach it. That route needs the ledgermaster role.
Roles come from a JWT signed with JWT_SECRET, and a seed script writes that secret to /app/.env when the container starts. Reading an arbitrary file needs the archive-extraction endpoints below, and those need an authenticated API caller. Which gave me a chain with a hole in the middle:
flag <- /readflag <- RCE <- ledgermaster JWT <- JWT_SECRET <- /app/.env <- authenticated API <- ???
The only authenticated party anywhere in the system is the report bot itself. It's seeded with a warden session and a relay key at container start. Nothing in the app exposes that key to a normal request. Everything below exists to get one string out of a browser I don't control.
Phase 1: leaking the bot's relay key
This took the longest by a wide margin, and most of it was wrong turns.
An early plan: CSRF the bot into uploading
My first idea skipped the relay key entirely. If I could get the bot's browser to submit an authenticated request on my behalf, I wouldn't need to know any of its secrets. The session cookie has no SameSite protection, so a same-origin POST from the bot's browser would ride along automatically.
That sent me down a real rabbit hole: Chrome's two-minute grace period for SameSite-less cookies on a fresh navigation, top-level popups instead of iframes (since Lax only forgives top-level navigation, not framed requests), and DataTransfer tricks to fake a multipart file part so the forged request would look like a file upload. This proved a custom file could land on the server, but it never became the way in. the final chain authenticates every later phase with the relay key directly, and phase 1 itself only needs a plain top-level GET, which Lax permits without any of this. I kept the CSRF work only long enough to confirm the upload endpoint was reachable at all.
A gadget that leaks JSON into an attribute
Looking for anywhere client-side JS makes its own fetch, the widget iframe that renders the transmission log stood out:
var current = new URLSearchParams(window.location.search).get('record') || 'held';
fetch('/api/feed/' + record + '.json', { credentials: 'include' })
.then(r => r.json())
.then(res => {
var value = res.data || '';
...
var panel = window.parent.document.getElementById('info-panel');
if (panel) panel.setAttribute('data-content', value);
})
This is a credentialed, arbitrary same-origin GET whose JSON response gets written straight into the parent frame. record has no server-side validation at all. Setting it to ../relay-key? turns the fetch target into:
/api/feed/ + ../relay-key? + .json -> /api/feed/../relay-key?.json -> /api/relay-key
The browser's URL normalization collapses .. the same way a filesystem path would, and the trailing ? turns the leftover .json into a query string instead of a path segment. The response comes back as {"data":"<12 hex chars>"}, and that lands here:
<div id="info-panel" data-content=""></div>
That <div> is dead markup. Nothing else in the app reads it. It exists only to receive this value, which is the clearest sign that leaking data through an HTML attribute is the intended route rather than a coincidence.
My first attempt at reading /app/.env reused this exact gadget, pointing record at ../../.env? instead. It failed for a boring reason: the gadget calls r.json() before it does anything else, and .env is JWT_SECRET=..., not JSON. The fetch throws before res.data is ever set. Whatever read .env was going to need its own bug, which turned out to be true, and is where phase 2 starts.
Getting past qs and URLSearchParams disagreeing
Getting a value into data-content is only half of it: nothing reads that attribute either. The partner injection sits in the same page:
var params = new URLSearchParams(window.location.search);
var theme = params.get('theme');
if (theme) document.getElementById('theme-display').innerHTML = theme;
guarded server-side like this:
const theme = req.query.theme;
if (theme && !allowedThemes.includes(theme)) return res.status(400).send('Invalid theme');
Two different parsers read the same query string. The app sets app.set('query parser', 'extended'), which routes req.query through qs. qs defaults to a 1000-parameter limit and silently drops anything past it instead of throwing. The browser's own URLSearchParams, the one running in the page, has no such limit. So I padded the query string:
/ledger?record=..%2Frelay-key%3F&z&z&z...(1010 times)...&theme=<payload>
record stays parameter 1, so the server still parses it correctly and renders it into the iframe's src. theme becomes parameter 1012, past qs's limit, so req.query.theme is undefined, the allowlist check gets skipped, and the page renders anyway. The browser's URLSearchParams, with no parameter limit, still finds theme and passes it straight to innerHTML.
While I had qs open, I also checked whether allowPrototypes was still its old permissive self, since a prototype pollution primitive on top of this would have been a much shorter writeup. It wasn't: modern qs guards __proto__ inside parseObject, and constructor[prototype][x] only ever sets an own property through utils.merge. Dead end, but a five-minute one.
The CSP wall, and the dead ends it caused
At this point I had two working primitives, an attribute write and an innerHTML sink, and a CSP that made both nearly worthless:
Content-Security-Policy:
default-src 'self';
script-src 'nonce-<16 random bytes, per response>';
style-src 'unsafe-inline' 'self' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
frame-ancestors 'self'; form-action 'self'; base-uri 'self'
- No script.
script-srcis nonce-only, the nonce iscrypto.randomBytes(16)per response, and scripts inserted viainnerHTMLnever execute regardless. - No egress.
img-src,connect-src,media-src, andframe-srcall fall back todefault-src 'self'. The classicbackground-image: url(https://attacker/?leak=...)is dead on arrival. - No framing.
frame-ancestors 'self'stops an attacker page from embedding the target and watching it. - No form exfil.
form-action 'self', and nothing clicks anyway. - No base hijack.
base-uri 'self', and every script tag uses an absolute path, so a same-origin<base>changes nothing.
I spent real time here on things that were never going to work. style-src and font-src both carve out fonts.googleapis.com and fonts.gstatic.com, so I tried routing an exfil request through a Google font URL. That's allowed by the policy, but the request lands on Google's servers, which I don't control and can't read. I also tried POST /api/convoys, which is unauthenticated for both reads and writes and satisfies form-action 'self', as a place to write leaked data to and read it back from. That fails because nothing on the page ever clicks a form and no script runs to submit one automatically. And early on, before any of this, I tried hosting the attacker page on webhook.site, which turned out to serve its own script-src 'none', killing script tags, inline handlers, and srcdoc alike before I'd even gotten to the target's CSP.
Uploading a file to /uploads and opening it directly doesn't help either: app.use(security) runs before express.static, so even planted HTML gets the same nonce CSP.
CSS as a signal, /depart as the effect
CSP allows inline styles, and a CSS attribute selector can act as a conditional: it only fires its declared network request when the attribute matches.
#info-panel[data-content^="a1"] { background-image: url(/depart) }
If the attribute doesn't start with a1, the browser never issues the request at all. default-src 'self' forces that request back to the app itself, which is normally a dead end, until I noticed the logout route:
exports.logout = (req, res) => {
res.clearCookie('token');
res.redirect('/enter');
};
A plain GET with a side effect that persists: logging the bot out. The browser doesn't care that the request was triggered by a CSS background image. The server responds with Set-Cookie: token=; Expires=Thu, 01 Jan 1970, and the session is gone. The stylesheet effectively reads:
If the relay key starts with
a1, destroy the bot's session.
Two details make this reliable. #info-panel is an empty <div> with no styling, so it paints no box and Chrome skips the background-image fetch entirely unless the injected rule gives it a size. And the selector re-evaluates whenever the attribute changes, so ordering takes care of itself: the injected <style> sits in the page from parse time, and the fetch fires the instant the widget iframe writes data-content.
Reading the answer through window.length
The bot visits the attacker page first, and that page carries no CSP of its own. From there it opens the target in a popup window (the bot launches Chromium with --disable-popup-blocking) and keeps a handle to it. A popup, not an iframe, since frame-ancestors blocks framing.
A cross-origin WindowProxy exposes almost nothing, but window.length, the number of nested browsing contexts inside it, is one of the few properties still readable across origins:
/ledgerwith a session renders two iframes (frame-dispatch,frame-log)./ledgerwithout one hitsif (!req.user) return res.redirect('/enter')and lands on a page with zero iframes.
Setting location on a cross-origin window is also allowed, so a page can send a window somewhere without ever reading what's inside it. The attacker page navigates the popup to /ledger and reads the frame count:
w.length |
meaning |
|---|---|
2 |
session alive -> selector did not match -> guess wrong |
0 |
session dead -> selector matched -> guess right |
The bot's own cookie rides along automatically. It's a top-level GET navigation, which SameSite=Lax allows with no extra trickery needed, and none of the CSRF machinery from earlier.
Cost, controls, and one probe's timeline
The cookie jar is per browser profile, and each bot.visit() launches a fresh browser, so a match destroys the session for the rest of that visit. One yes/no answer per bot visit.
The key is crypto.randomBytes(6).toString('hex'), 12 characters over a 16-symbol alphabet. Rather than 16 questions per character, each probe tests half the remaining alphabet at once by listing eight selectors:
#info-panel[data-content^="a0"], #info-panel[data-content^="a1"], ... { background-image: url(/depart) }
16 -> 8 -> 4 -> 2 -> 1 is four probes per character, 48 probes for the whole key, plus two controls. About 15 to 20 minutes at roughly 12 seconds of page time each. I drove the whole search from a small local server: it hands the bot's browser the current guess, collects the answer back, and refuses to start the real search until two control probes disagree the way they should:
#info-panel[data-content], matching any element with the attribute at all, must return0.#info-panel[data-content^="zzz"], since the key is hex, must return2.
That always-match result carries a lot of weight. A single 0 there proves the record traversal reached /api/relay-key, the bot's cookie was attached, the response reached data-content, theme survived qs truncation, the CSS applied, /depart fired, and window.length reads correctly. Without it the search runs on noise.
One probe, end to end:
POST /report. The app launches Chromium, sets the bot cookie, and navigates it to the attacker page.- The attacker page fetches its probe from its own origin (unrestricted) and opens the popup at the padded
/ledgerURL. /ledgerrenders, the widget iframe fetches/api/relay-key, and writesdata-contenton the parent.- The injected CSS matches or it doesn't, so
/departfires or it doesn't. - After 6 seconds:
w.location = '.../ledger'. - Two samples of
w.length, two seconds apart, because a navigation still in flight shows the old count and would read as a false negative. Disagreement gets reported asunstableand re-run. - The result posts back to the attacker's own server.
Phase 2: reading /app/.env
With the relay key in hand as an x-api-key, the browser is no longer needed at all. resolveAuth accepts that header from any verified user, and the bot is verified.
Two extractors, and only one lets you create a symlink
Archonyx has two separate zip-extraction endpoints. /api/manifest runs through unzipper, and I tried the symlink idea there first: upload a symlink entry pointing at /app/.env and read it back through whatever the app serves. It doesn't work, structurally. unzipper derives entry.type only from "size 0 and ends in /", and the symlink bit lives in externalFileAttributes, which exists only in the central directory. A streaming parser never reads that far, so Extract cannot create a link no matter what the archive contains.
The app's other extraction endpoint downloads and unpacks what it calls a "mirror station bundle", through decompress@4.2.1 instead of unzipper. That version already contains the standard zip-slip fix, and it's thorough about writes: safeMakeDir resolves the real path of every parent directory before creating it, and preventWritingThroughSymlink refuses to write to a destination that's already a symlink. Neither guard covers creating a symlink in the first place. A symlink entry is handled by fs.symlink(x.linkname, dest), and linkname comes straight from the archive with no validation. The destination path stays inside the output directory, so both guards pass, but the link itself can point anywhere on the filesystem.
Why the unknown UUID doesn't matter
The link lands in /app/uploads/imports/<uuid>/, and no endpoint exposes that UUID. That looked fatal until I found the function that turns a filename into a path on disk:
function resolveFilePath(caller, filename) {
if (!filename || path.basename(filename) !== filename) return null;
if (!caller.drawsId) return null;
return path.join(uploadsDir, 'imports', caller.drawsId, filename);
}
The server supplies the UUID from the caller's own record, and fs.readFile follows symlinks. A request for env makes the server resolve the directory and dereference the link on my behalf. I never need to know the UUID at all.
Racing the cleanup loop
validateExtractedFiles reads the symlink, sees .env isn't an image, and deletes it. My first plan was to pad the archive with thousands of valid PNGs, so the cleanup loop, which runs sequentially and does one readFile plus one magic-byte check per entry, would take several seconds to reach the symlink and I'd have a window to read it first.
That reasoning was wrong, which is why a naive upload-then-read only won about half the time. The loop iterates fs.readdirSync(dir), and on ext4, readdir returns entries in filename-hash order, not creation order. Padding puts the symlink at a random position in that order, and adding more padding only shifts the average, not the outcome.
The real fix is about timing, not padding: start reading while the archive is still being extracted, well before the response comes back or cleanup begins. The order in which the endpoint does its work makes this possible:
await uploadService.downloadAndExtract(url, extractDir); // link exists partway through
res.json({ data: 'Mirror station bundle fetched and lodged' });
setImmediate(() => uploadService.validateExtractedFiles(extractDir));
decompress extracts all entries concurrently, and the symlink is first in the archive, so it exists within milliseconds of extraction starting, while the 3,000 padding files are still being written, well before the response is sent and well before cleanup begins. So I stopped waiting for the response entirely: eight reader threads start hammering /api/cargo/env/raw before the upload request is even issued, which turns a coin-flip into a near-certainty.
A second improvement widens the window further. readFile follows symlinks, so the padding entries can themselves be symlinks to a large existing file (/usr/local/bin/node, about 110 MB) instead of real PNGs. Each one then costs a full multi-megabyte read before file-type rejects it, stretching the cleanup loop to tens of seconds without writing any real data to disk.
Phase 3: forging the token
JWT_SECRET is all that's needed, since the app trusts a token's contents once the signature checks out. There's no expiresIn at signing, jwt.verify checks nothing but the signature, and authorization reads the role straight out of the payload instead of re-reading the user from the database the way resolveAuth does. So a token payload of {"username":"admin","role":"ledgermaster"}, signed with HS256, is indistinguishable from a genuine admin session. Forging one took twenty lines and no dependencies: base64url-encode a header and that payload, HMAC-SHA256 the two together with the leaked secret, and concatenate the three parts with dots.
Phase 4: planting the payload
Two ways to read the same zip
A ZIP describes its contents twice:
[ Local header "../bot_x/evil.js" ][ payload bytes ] <- streaming readers start here
[ Local header "ok.png" ][ PNG bytes ]
[ Central directory: "ok.png" -> offset of the PNG local header ]
[ End of central directory ]
Each file is preceded by a local file header (PK\x03\x04) holding its name and size. At the end sits the central directory (PK\x01\x02), an index of every file's name plus the byte offset of its local header. Random-access readers seek to the end and read the central directory, which is how a zip tool lists contents instantly. Streaming readers can't seek backwards, so they parse forward from byte 0 using local headers. Nothing in the format requires the two descriptions to agree, and Archonyx splits exactly on that line:
const directory = await unzipper.Open.buffer(buffer); // random access -> central directory
...
stream.pipe(unzipper.Extract({ path: extractDir })) // streaming -> local headers
validateArchive iterates directory.files and applies three checks: an extension allowlist, a .. substring check, and magic-byte sniffing via entry.buffer(). That last call seeks to the offset the central directory declares, so it reads the genuine PNG and passes. extractArchive then re-parses the same bytes streaming, hits the local header at offset 0 naming ../bot_x/evil.js, and writes it. The validator never saw that entry exist.
Why the traversal guard doesn't catch it
unzipper.Extract does guard against zip-slip, but with a string comparison:
const extractPath = path.join(outPath, entry.path);
if (extractPath.indexOf(outPath) != 0) return entry.autodrain();
With outPath = /app/uploads/workspace/bot, an entry named ../bot_x/evil.js joins to /app/uploads/workspace/bot_x/evil.js. That string starts with /app/uploads/workspace/bot, so indexOf returns 0 and the check passes, even though bot_x is a different directory. The comparison matches string prefixes where it should match path components. A correct check would compare against outPath + path.sep, or use path.relative() and reject results starting with ...
Real traversal (../../../views/enter.ejs) is caught, because path.join normalises it to /app/views/enter.ejs, which shares no prefix with outPath. So the escape is confined to sibling directories whose names happen to extend the output path. I tried harder anyway: if writes could reach /app/views at all, views/enter.ejs would have been a much shorter path to root. The Dockerfile never sets NODE_ENV=production, so Express's view cache stays off and EJS templates get re-read from disk on every render. An .ejs file is server-side JavaScript, so overwriting one is code execution with no admin role needed. It doesn't work, because both extractors' prefix checks confine writes to paths that start with their own output directory, and neither /app/views nor /app/data starts with /app/uploads.
Why the file survives, and why the URL is predictable
validateExtractedFiles deletes anything that isn't a PNG or JPEG, but it only scans the directory it was handed, workspace/bot. A file sitting in workspace/bot_x is never enumerated and never deleted. Combined with /uploads being served statically with no authentication, that leaves an arbitrary file, with an arbitrary extension, at a predictable public URL. There's no UUID involved: the relay key belongs to the user bot, and getExtractDir keys off the username.
Phase 5: getting code execution
With the forged token from phase 3 in hand, one authenticated request against the instance's base URL ($PUB below) points the render endpoint at the planted file:
curl -s -X POST $PUB/ledgermaster/render -b "token=$JWT" \
-H 'Content-Type: application/json' \
-d '{"css":"@plugin \"/app/uploads/workspace/bot_x/evil.js\";"}'
Less's plugin loader reads the file and evaluates it with
new Function('module','require','registerPlugin','functions','tree','fileInfo','less','pluginManager', contents)
A real Node require gets passed in as the second argument, so this is arbitrary JavaScript running as the user ctf. From there, /readflag is setuid root.
There's a guard meant to stop plugins from loading remote URLs: it installs a file manager whose supports() is /^[a-z][a-z0-9+\-.]*:\/\//i, so it only claims absolute URLs, and any filesystem path falls through to the default loader. Relative paths resolve from process.cwd() (/app). I used the absolute form because it skips Less's search-path logic entirely, removing one variable from an already ambiguous failure mode.
A response that tells you nothing
Worth writing down, since it cost real debugging time. I first read the response {"error":"Seal casting failed"} as proof the plugin had run and Less had merely rejected its output, in other words, success. That response is actually ambiguous: localModule.exports starts as {}, which is truthy, so Less can accept the file as a valid empty plugin and return {"data":"Seal cast"} after running it, but a plain 500 equally means the file was never found in the first place. Both outcomes can produce either response.
The fix was to stage the payload with markers and drop the try/catch that was swallowing the only real evidence. Each stage writes a file under /uploads/workspace/bot_x/, so whichever markers show up localizes the failure: no markers means Less never loaded the file at all, a_ran alone means require failed, and a plus b without d means /readflag itself is the problem. Once d appears, curl $PUB/uploads/workspace/bot_x/d_flag.txt returns the flag.
Bugs and fixes
| # | Bug | Fix |
|---|---|---|
| 1 | The zip validator reads the central directory, while the extractor streams local file headers, so the two see different archives. | Validate and extract from a single parse, or reconcile the central directory against local headers before trusting either. |
| 2 | Zip-slip guard is a string-prefix check (extractPath.indexOf(outPath) != 0) rather than a path check. |
Compare against outPath + path.sep, or use path.relative(outPath, extractPath) and reject results starting with ... |
| 3 | decompress@4.2.1 blocks writing through a symlink but does not restrict creating one. linkname comes straight from the archive. |
Reject symlink and hardlink entries outright, or resolve linkname and require it to stay inside the output directory. |
| 4 | The raw-file endpoint builds its path from the caller's own record server-side, and fs.readFile follows symlinks. |
fs.readFile with O_NOFOLLOW, or lstat the resolved path and refuse symlinks. |
| 5 | /uploads is served statically with no authentication. |
Authenticate /uploads, or serve user content from a separate origin. |
| 6 | record is concatenated into a credentialed same-origin fetch path, and the result is written into the parent frame's DOM. |
Validate record against an allowlist, and stop writing across frame boundaries. |
| 7 | theme goes into innerHTML. The server validates with qs, the client sink reads with URLSearchParams. |
Parse the query string once. If the server validates with qs, the client must not re-read with URLSearchParams. Use textContent instead of innerHTML. |
| 8 | Logging out is a GET that deletes the session cookie, a state-changing GET. |
Make logout a POST with a CSRF token. A GET that mutates state is a side channel by construction. |
| 9 | Authorization reads the role from the token payload instead of re-reading the user. No expiresIn at signing. |
Re-read the user from the database on every authorization check, exactly as session auth does. Set expiresIn, and pin algorithms: ['HS256'] in verify. |
| 10 | less.render runs on user input. The guard only rejects scheme:// filenames. |
Don't compile untrusted Less. If you must, strip @plugin before parsing, since a file-manager guard can't stop it: @plugin doesn't go through URL resolution. |
Also worth doing regardless of any single bug: set sameSite: 'strict' on the session cookie, and NODE_ENV=production so views are cached rather than re-read from disk on every request.
Conclusion
The five phases run cleanly start to finish now, but very little of the actual work went in that order. The CSRF plan that opens phase 1 produced a real, working file upload, and it felt like progress for the length of time it took me to build. It wasn't: the final chain authenticates every phase with the relay key directly, and a plain top-level GET gets past SameSite=Lax without any of that machinery. The same pattern shows up in phase 2. Padding the symlink archive with more files felt like it should widen a race window, and it "worked" often enough, about half the time, that the reasoning behind it went unquestioned for a while. It was wrong: readdir on ext4 orders by filename hash, not by write order, so padding just moves the odds around a fixed average instead of buying time. And in phase 5, {"error":"Seal casting failed"} read as confirmation that the plugin had loaded and merely been rejected, when it was equally consistent with the file never having been found at all.
In each case the fix was the same: stop trusting a response that was consistent with success and go verify the mechanism directly instead. Reading readdir's actual documented order settled the padding question in a few minutes. Staging the payload with per-step marker files turned an ambiguous JSON body into an unambiguous filesystem trail. Neither experiment was hard to run. They just weren't the first thing I reached for, and the gap between them cost more time across the chain than any single phase's exploit did.
