L1 掃描結果:License 驗章核心(密碼學層)

L1 掃描結果:License 驗章核心(密碼學層)

掃描日期:2026-09-06 掃描版本:jedi-python-package feature/FR-075 @ 67cb075941bd6fd57f1e05a76bc56d5fed984c70(乾淨,無未 commit 變更) 工具:Claude Code claude-security plugin v0.10.2.3(claude-security:scan workflow) 範圍jedi-license-runtime/jedi_license_runtime/common,10 個受版控檔案(啟動前用 git ls-files 驗過,與卡片數字吻合) effortlowfocusattack-surface 模型:主 session Opus 5 (1M context),研究員繼承 狀態:✅ 流程完整跑完,驗證面板 verification.status: verified(讀 CLAUDE-SECURITY-REVISION-67cb075941bd.jsonverification 欄確認,非自述) 對應卡片:CM-1567(母卡 CM-1566)

§1

一句話結論

範圍內找到 1 條 HIGH,屬實:任何登入使用者都能用一個約 1.4MB 的請求,讓後端在驗章之前先配置 1GB 記憶體,把 API 打掛。另外 2 條(MEDIUM/LOW)是研究員讀出範圍外的檔案找到的,事實成立但不屬本棒收穫。

§2

執行概況

項目 數字
研究員派出 / 回報 2 / 2(無 stalled,無重試)
候選發現 4 條(去重後仍 4)
面板票數 12 票(4 條 × 3 票,無缺票)
面板存活 3 條(F4 被 3:0 否決)
總耗時 約 55 分鐘(3,288 秒)
agent 總數 14(全數完成,0 失敗、0 空回報)
token 消耗 約 129 萬

與 FR-075 的對照:S4~S7 用 Sonnet 5(200K)跑 40 檔以上四棒全滅、共燒 29.6 小時零產出。本棒改用 Opus 5 (1M) + 10 檔範圍,55 分鐘一次跑完、零 stalled。「Opus 1M + 小範圍」這個組合驗證成立,L2~L4 照此設定跑。

§3

⚠️ 研究員再次越界讀檔(與 FR-075 S7 同一模式)

面板確認的 3 條裡,只有 1 條在指派範圍內

ID 標題 檔案 在範圍內?
F1 驗章前無界解壓縮(解壓炸彈) jedi-license-runtime/.../common/engine.py:114
F2 GitHub/GitLab PAT 進版控與已發佈 wheel jedi-issue/dist/*.whl ❌ 否(jedi-issue)
F3 Turnstile 退回永遠通過的測試金鑰 jedi-iam/.../turnstile/verifier.py:38 ❌ 否(jedi-iam)

F2/F3 事實面我都核對過、確實成立,但屬於別的套件。面板不驗證「發現是否在指派範圍內」(它只驗「這條成不成立」),所以越界不會被攔下——這是 plugin 的已知行為,L2~L4 收報告時要同樣自己挑掉範圍外的。

F3 另有一個處置參考價值:verifier.py 的 docstring 顯示作者已經意識到「驗證永遠通過是不會報錯的病」,卻仍保留 fallback。那是有意識的取捨(開發便利 vs 失效靜默),不是無知疏漏,裁決時值得納入考量。


§4

F1(範圍內唯一發現)— 驗章前的無界解壓縮:解壓炸彈打掛 API

嚴重度:HIGH · 信心:HIGH · 面板:3/3 判定成立 · CWE-409 位置jedi-license-runtime/jedi_license_runtime/common/engine.py:114_unpack_payload

問題在說什麼(白話)

License 有兩種信封格式。v2 格式的照,內容是壓縮過再 base64 的,所以驗章之前必須先解壓縮還原成原文。程式碼就照這個順序做:先解壓(engine.py:179),再驗簽章(engine.py:213)。

問題是解壓縮沒有設上限。攻擊者送一個「壓縮得極小、解開極大」的假 payload(解壓炸彈),後端會先老老實實把它全部解開、吃掉幾 GB 記憶體,然後才走到驗章那一步發現簽章是假的——但這時記憶體已經爆了,process 被 OOM 殺掉。簽章根本來不及保護任何東西。

實際影響

我實測過放大倍率(不是照抄工具宣稱):

1GB 的零 → 壓縮後 1,043,644 bytes → base64 後 1,391,528 bytes
放大倍率:772 倍(相對於請求 body 大小)
推算:10MB 的請求 body → 展開約 7.5 GB

也就是一個 1.4MB 的 HTTP 請求就能讓後端配置 1GB。配置發生在請求執行緒內、同步進行,所以:

  • 一般部署:gunicorn worker 被 OOM 殺掉
  • 落地版(單容器 stack):整個 guidant-api 容器被殺 —— 全產品停擺

而且這個請求極廉價可重複,攻擊者連續送就能讓服務持續起不來。這是純可用性攻擊,不影響機密性與完整性(簽章該擋的它還是擋得住,只是來不及擋)。

觸發前提(三項我都逐一核實過)

  1. 攻擊者只需要一個有效 JWT —— 端點 /api/1.0/license/activation/upload 只掛 jwt_required(),無管理員角色、無 capability 檢查、無需已有授權。這是刻意的設計:程式碼註解寫得很清楚,「無照租戶唯一的自救路徑,套上管理員守門的話,還沒開通的租戶就再也開通不了」。所以這不是漏掛守門,而是防死鎖設計必然敞開的一扇門——正因為敞開,門後的東西更需要自我保護。
  2. 上游沒有 body 大小限制 —— BE 全 codebase 無 MAX_CONTENT_LENGTH(grep 確認),落地版 nginx 是 client_max_body_size 0(FR-065 design.md:218,刻意設的,為了大檔上傳與長工時匯出)。兩層都不擋。
  3. 信封要帶 format_version: 2 —— 這完全由攻擊者控制,request.get_json() 之後直接 payload.get("license") 丟進 verify_license,中間沒有任何 schema 驗證。

補充:唯讀 gate 對 /license/ 前綴有整段豁免,所以連被鎖定的租戶也送得出這個請求

建議修法

不能靠「把驗章移到解壓之前」解決 —— 簽章簽的正是解壓後的 canonical bytes,順序不能對調。必須加上限:

def _unpack_payload(packed: str) -> bytes:
    raw = base64.b64decode(packed)
    d = zlib.decompressobj()
    out = d.decompress(raw, MAX_PAYLOAD_BYTES)   # 有界解壓
    if d.unconsumed_tail:                         # 還有剩 = 超過上限
        raise ValueError("payload 超過大小上限")
    return out

MAX_PAYLOAD_BYTES 設幾百 KB 即可(一張照的 payload 是很小的 JSON)。另建議兩道縱深:解碼前先擋掉過長的 payload 字串,以及在 host Flask app 設 MAX_CONTENT_LENGTH,讓無上限的 body 根本進不到應用層。

額外發現(研究員沒報,我在核對時查到的)

同款無界解壓在 jedi-integrity 還有兩處:

  • jedi-integrity/harness/dev_app.py:103
  • jedi-integrity/tests/conftest.py:84

兩處都不是產品路徑(開發 harness 與測試),不構成線上風險,但顯示這個 pattern 在簽發/驗證鏈是複製擴散的。修 F1 時建議一併處理,避免日後有人照抄舊寫法再種一次。


§5

F2(範圍外)— GitHub/GitLab PAT 進了版控歷史與已發佈的 wheel

嚴重度:MEDIUM · 信心:MEDIUM · 面板:2/3 判定成立 · CWE-798 位置jedi-issue/dist/jedi_issue-0.0.15-py3-none-any.whl(原始碼路徑 jedi-issue/jedi_issue/.env 已於 d6a8d09 刪除)

事實核對:屬實。我用 unzip -l 確認 jedi_issue/.env(292 bytes)至今仍實體存在於 0.0.14 與 0.0.15 兩個 wheel 內。原始碼路徑在 2026-07-25(d6a8d09)刪掉了,但那不會清掉 git 歷史,也不會清掉已經推上內部 Nexus 的成品。

pyproject.tomljedi_issue/ 整包打包,所以 .env 被建進了發佈物。任何人只要 git show d6a8d09^:jedi-issue/jedi_issue/.env,或從 Nexus 拉 0.0.14/0.0.15 解開,都拿得到那兩把 token。

注意:d6a8d09 的 commit message 宣稱 token 已撤銷,但那是作者宣稱、無法從程式碼驗證

建議:① 到 GitHub/GitLab 後台實際確認兩把 PAT 已撤銷(不要只信 commit message);② 從 Nexus 刪掉 jedi_issue 0.0.14/0.0.15,並清掉本地 dist/ 殘留;③ 歷史 blob 用 filter-repo/BFG 清掉,或接受歷史、把那兩把 token 當永久燒毀處理;④ 往後 .env 不進打包路徑,加 pre-commit secret scanner。

本報告不記錄 token 實際值。

§6

F3(範圍外)— Turnstile 沒設密鑰時退回「永遠通過」的測試金鑰

嚴重度:LOW · 信心:MEDIUM · 面板:2/3 判定成立 · CWE-1188 位置jedi-iam/jedi_iam/turnstile/verifier.py:38_resolve_secret_key

事實核對:屬實。TURNSTILE_SECRET_KEY 為空/不存在/JSON 解析出空值時,回傳 Cloudflare 官方文件記載的「永遠成功」測試金鑰,於是 verify() 對任何 token 都拿到 success: true。登入的人機驗證等於關閉,且無錯誤、無 log,從外部看不出這個部署的 Turnstile 是活的還是死的。

影響有限而非致命:帳號鎖定機制(MAX_LOCK_COUNT / USER_LOCK_TIME)仍在,單一帳號的暴力猜測還是有上限。攻擊者實際得到的是不受節流的帳號列舉與跨帳號橫向噴灑

值得注意的取捨:該函式 docstring 明白寫著改成「每次呼叫重讀」的理由是——模組級快取會導致「開機後才接線設定讀取器」時快取到測試 key 並永久沿用,「症狀是驗證永遠通過這種不會報錯的病」。也就是作者已經看見這個病,卻選擇保留 fallback。要不要改屬產品決策,不是純技術疏漏。

建議:密鑰解析為空時 fail closed(拒絕驗證),要關 CAPTCHA 就走既有的 enable/disable 開關明示關閉。若要保留開發便利預設值,綁在 debug flag 上並在啟動時印警告。


§7

覆蓋率與「零發現」的分界

  • 範圍 10 檔全數在研究員讀取範圍內,collapsed: null(未退化成小範圍簡化流程),跑的是完整管線
  • 無元件被丟棄或跳過、無 bucket 被裁剪、無對抗階段損耗、無因上限而未驗證的候選
  • focus 有設,所以另跑了一輪專門的密鑰掃描(sweep:secrets

關於卡片提的六個重點,哪些「讀過但沒找到問題」:研究員的候選集中在 engine.py 的解壓路徑,卡片點名的其餘幾項——v1/v2 降級、payload_type 型別混淆、canonical JSON 兩端一致性、時鐘回撥浮水印、機器指紋 fallback、三環境硬編公鑰——都沒有產生候選發現

這裡要誠實區分:這些檔案確實在研究員的讀取範圍內(10 檔全在 scope,且 engine.pymachine_fingerprint.pypublic_keys.py 正是被讀出 F1 的同一批檔案),所以不是「根本沒讀到」。但「沒回報候選」也不等於「已被逐項排除」——low effort 只派一位研究員通掃,不保證對每個假設都做過針對性推演。三環境硬編公鑰(DEV 私鑰簽的照 POC 也認)尤其值得留意:它在程式碼裡是明擺著的事實,研究員沒把它當成發現,可能是判定為刻意設計,也可能只是沒往那想。這一項建議由決策者直接裁定,不要等下一輪掃描。

§8

交付物

掃描原始產物在 jedi-python-package repo 的 CLAUDE-SECURITY-20260906-031103/(該目錄有自己的 .gitignore,預設不進 commit):

  • CLAUDE-SECURITY-RESULTS.md — 工具產出的英文報告
  • CLAUDE-SECURITY-RESULTS.jsonl — CI gate 用
  • CLAUDE-SECURITY-RESULTS.sarif — code scanning dashboard 用
  • CLAUDE-SECURITY-REVISION-67cb075941bd.json — 版本與驗證戳記
§9

建議後續

項目 建議
F1 開修正卡(範圍內唯一發現,HIGH,修法明確且小),一併處理 jedi-integrity 兩處同款寫法
F2 轉給 jedi-issue 負責人;先做的是去後台確認 PAT 撤銷狀態,這比改程式碼急
F3 屬 jedi-iam,與 FR-075 S3(MFA/Turnstile)同範圍,併入該案裁決
三環境硬編公鑰 掃描沒回報,但事實明擺著,建議決策者直接裁定是設計還是疏漏
L2~L4 照本棒設定跑(Opus 1M + effort low + focus attack-surface);收報告時自己挑掉範圍外的發現