檢查日期 2026-09-19|對應卡片 CM-1874(D2 第 1 小棒的後半)|檢查範圍 2 個檔案 422 行
找到 1 條中風險:防「壓縮檔炸彈」的那道「最多一萬個檔」上限,對 .zip 格式形同虛設——程式要等整份清單都讀進記憶體了才開始數,數到第一萬零一個喊停時記憶體早就吃掉了。 我實測過:一個 49.7MB、裝 59.5 萬個空檔的 zip,光「打開」這個動作就讓記憶體多吃 360MB,連送幾次就能把後端工人打掛。要先是租戶管理員才打得到,所以不急。修法很小——打開之前先讀 zip 檔尾的目錄筆數欄位,超量直接擋。同時要改掉那段寫反的註解,它正是這個洞活下來的原因。
另外推翻一項卡片上的錯誤前提:卡片說「15 條基準 route 一顆能力點都沒掛」,實查不成立——18 支方法裡 10 支掛了能力點,而且掛的正好是全部的寫入類,讀取類不掛是合理設計。這個錯誤前提在開卡當下(a6475b4)就已經是錯的。
客戶要做弱點掃描,得先給系統一包「掃描規則」——實務上就是上傳一個壓縮檔(zip 或 tar)。系統收到之後不會馬上解開,而是先隔著檔案檢查一遍:這是不是真的壓縮檔、有沒有大到離譜、裡面的檔案會不會解出去寫到不該寫的地方。
這一棒只看這道關卡本身(兩個檔):
detection_profile_archive.py(347 行) — 那道關卡的全部實作。它自己在檔頭寫明了做哪些檢查、也自己承認了界線(「炸彈檢查靠檔案自己宣告的大小,是宣告值不是實讀值,理論上可被偽造」)。這一棒要驗的就是:自陳的防護真的成立嗎?自陳的界線之外有沒有真的路?detection_profile_ref.py(75 行) — 一支 75 行的格式約定:掃描規則下發給代理程式時,怎麼表示「這是規則庫裡的第幾號」而不是「使用者自己填的網址」。掃描目標 /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection,revision 955e40964baa(branch feature/review,工作區乾淨),mode scan,effort low,範圍 2 檔 422 行。
low 強度:一位研究員讀完這 2 個檔案就提報候選,未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描,completenessCheckOutcome 為 not-applicable(低強度本來就不跑盤點,而且範圍是指定檔案不是整棵樹)。驗證跑了 1 輪,2 個候選去重後仍是 2 個,6 票全數投出,沒有候選遺失、沒有嚴重度被降低、沒有候選被略過。
研究員為了追資料流另外讀了範圍外的幾處(建構驗證器的基準服務、safe_http_fetch、profile_extractor/inspec.py,以及為了那條被駁回的候選讀了主專案的任務綁定處理器、編排服務、代理程式取檔服務),那些只當佐證、沒有納入稽核。
研究員沒有回報「哪些檔案沒讀完」的自述(coverage.research 為 null),所以工具端沒有做讀取完整度的交叉檢查。
工具沒有實際執行任何專案程式碼:沒跑測試、沒發請求、沒示範攻擊,所有判斷都是讀原始碼推出來的。唯一的例外是首腦核對時在一個拋棄式的 Python 直譯器裡實測 CPython 的 zip 套件行為(自己造一個合成壓縮檔、量打開它要吃多少記憶體)——那沒碰到專案的任何程式碼、也沒改任何狀態,純粹是為了把「理論上會」變成「實際數字」。
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- F1(zip 一萬個檔上限形同虛設)=M03 第 4 條,✅ 已修(CM-2057)。
| 編號 | 這是什麼問題 | 出事會怎樣 | 要先有什麼 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| F1 | zip 的「最多一萬個檔」上限在記憶體吃完之後才生效 | 一次合法上傳就讓後端多吃 360MB,連送幾次把工人打掛、API 服務中斷 | 租戶管理員身分 + 自製 zip | detection_profile_archive.py:249 |
MEDIUM |
被駁回 1 條(內部欄位沒過濾就寫進派工參數),而且它整條都落在本棒範圍外(任務綁定與編排服務),理由見下方。
這是什麼問題。 程式想防的是「壓縮檔炸彈」——檔案本身很小,解開卻爆出天量內容。防線之一是「最多一萬個檔」,寫法是一邊逐個掃、掃到第一萬零一個就喊停(:157-160)。這個寫法對 tar 系是對的,因為 tar 天生就是一筆一筆往下讀。
但 zip 不是。 zip 的檔案清單集中放在檔案尾端(叫 central directory)。Python 的 zipfile 套件在執行 ZipFile(buffer) 這一行——也就是「打開」這個動作——的建構子裡面,就會把整份清單全部展開成記憶體物件(CPython 的 _RealGetContents())。等程式碼回到迴圈開始數第 1 個、第 2 個……數到第 10001 個喊停時,六十萬筆物件早就全部躺在記憶體裡了。
這道上限的設計目的是「別讓記憶體被吃爆」,結果它擋的只是「別讓後面的檢查跑太久」——真正要防的那件事,在上限生效之前就已經發生。
出事會怎樣。 我實測了(CPython 3.11.9,就是專案用的版本):
| 檔案 | 裡面裝什麼 | 打開它要多吃多少記憶體 |
|---|---|---|
| 49.7 MB 的 zip | 59.5 萬個空檔案 | +360 MB(實測常駐記憶體增量) |
再加上讀檔那一段本來就會有兩份副本的峰值(_read_all_with_limit() 先把碎片收進 list、最後再 b"".join() 合成一份,:199-215),一次請求的記憶體高峰遠不只 410MB。預設是 4 個 gunicorn 工人(main.py:265),幾個併發請求就足以把工人吃到被系統砍掉,API 在那段期間對所有使用者都是慢或不通。
打完之後系統會回一個 HTTP 400「壓縮檔不安全」——看起來像是被擋下來了,實際上錢已經付掉了。這是這個洞最難察覺的地方:從日誌看起來防線運作正常。
要先有什麼才打得到。
detection-profile.create,這顆是客戶層能力點 is_platform=false,租戶管理員就有)@require_license("detection-profile")).tar / .tar.gz / .tgz)不受影響,因為 tarfile 是逐筆迭代的檔案本身完全合法:每個 entry 的檔名都是正常字元、沒有 ..、沒有絕對路徑、沒有 NUL、沒有 symlink,所以四道 entry 安全檢查全部會放行;再放一個頂層 inspec.yml 連結構檢查都過得了。沒有一道現有防線攔得住它。
在哪裡。
jedi-detection/jedi_detection/common/detection_profile_archive.py:249 —— with zipfile.ZipFile(buffer) as zf:,建構子當場展開整份清單:157-160 —— if entry_count > self._max_entries: raise,迴圈跑到第 10001 圈才會到:145-148 —— docstring 明寫「不先 materialize 成 list:50MB 的壓縮檔可以在 central directory 塞進上百萬個 entry,先全部收進記憶體再檢查,等於讓『entry 數上限』這道防線在生效之前就先被繞過」。這段話把問題描述得完全正確,但它描述的正是現行 zip 路徑真實發生的事——作者想防的就是這個,寫出來的程式碼對 tar 做到了、對 zip 沒有:199-215jedi_detection/api/routes/detection_profile_route.py:184(新增基準)、:359(改某一版的來源)、:410(版更)、:542(重新抽取)——四支都吃使用者上傳的檔案怎麼修。 兩件事,都要做:
_iter_zip_entries() 進到 ZipFile() 之前先讀這段(可用標準庫的 zipfile._EndRecData,或自己解析尾端那幾十個 bytes——它是固定格式),筆數或清單大小超標就直接丟 DETECTION_PROFILE_ARCHIVE_UNSAFE,不要開 ZipFile。這樣上限才真的擋在記憶體配置之前。:145-148 那段 docstring。 現在它告訴下一個維護者「這道防線已經做好了」,而那對 zip 不成立。這段註解本身就是這個洞能活到今天的原因——任何人讀了都會以為這裡已經處理過。改成寫明「tar 逐筆迭代天然滿足;zip 的清單在 ZipFile() 建構時就整份展開,所以上限必須在開檔前用 EOCD 先判」。⚠️ 修的時候注意:不要改成「先把 entry 收進 list 再數」,那會讓 tar 也一起中招。兩個格式共用同一套 entry 檢查是這個模組刻意的設計(檔頭 D4 段寫明理由:「按格式分支處理正是 zip 路徑漏防的典型成因」),要保留;要加的是 zip 專屬的開檔前預檢,不是改共用的那段。
驗證。 3/3 三位檢查員確認成立(可達性、影響、既有防護三個角度),嚴重度維持 MEDIUM。三位各自獨立去翻了 CPython 的 zipfile 原始碼,都定位到 _RealGetContents() 在建構子裡被呼叫、清單在 infolist() 之前就已經建好。
為什麼是 MEDIUM 不是 HIGH: 要先是租戶管理員(不是外面的人打一個網址就中),而且打不到任何資料——不會外洩、不會竄改,只會讓服務變慢或倒掉。
首腦核對註記。 屬實,而且比工具報的更具體。工具用的是推估(「粗估數百 MB」),我實際造了合成檔案量測:49.7MB/59.5 萬 entry → 常駐記憶體 +360MB,ZipFile() 打開耗時 1.7 秒。另外確認了三件工具沒講的事:① tar 路徑確實不受影響(tarfile 的 for member in tf 是逐筆讀 header,不預先展開);② 四支上傳端點全部吃同一支驗證器(detection_profile_service.py:115 建的那一個),所以修一處四支都好;③ 那段寫反的註解是關鍵——它不只是文件錯誤,是讓這個洞通過所有 code review 的原因,修法必須含它。與前面棒次不重複:D1 查的是工具憑證、D3 五棒查的是執行編排,都沒碰到上傳驗證這一段。淨新增 1 條,登記跨 arc 總表。
卡片重點①(壓縮檔驗證界線)、④的一部分(route 能力點)、⑦(離線工具)落在本棒;其餘落在 D2 的其他小棒。
卡片指名要驗檔頭第 20 行的自陳:「bomb 檢查靠 entry 自報的 size 累加,是 header 值不是實讀值,理論上可被偽造」。逐項查證結果:
自陳的那個弱點本身,實際影響比自陳講的還小——方向是對的。 偽造 header 大小確實做得到(宣告 1KB 實際 1GB),但那樣做繞不過任何東西:這一層只看宣告值,偽造小了就是讓它通過這一層,而真正解壓在代理程式那端,那邊拿到的實際內容與宣告不符會失敗。檔頭自己也寫了「agent 端另有一套同等防護,本層是第一道而非唯一一道」。判定:自陳成立,這個界線是有意識的取捨、不是缺陷。
但自陳漏講了另一個弱點,而且那個是真的 —— 就是 F1。檔頭花了整段講「bomb 檢查的數值可能被偽造」,卻沒察覺連「數」這個動作本身都太晚。自陳的界線畫在「數值可不可信」,真正的破口在「什麼時候開始數」。
其他四道防護逐一查證,都成立:
match_extension() 的 sorted(ALLOWED_EXTENSIONS, key=len, reverse=True)(:295)。這是必要的:os.path.splitext("a.tar.gz") 只會切出 .gz,而裸 .gz 不在白名單,用 splitext 會把合法的 tar.gz 一起擋掉。比對前先取 basename(防有人把路徑當檔名送)並轉小寫。判定:正確。_assert_magic()(:218-226)。zip 認三種 signature(含空 zip 的 PK\x05\x06 與 spanned 的 PK\x07\x08,否則合法空包會被誤判成假檔);tar 沒有檔頭 magic,看 offset 257 的 ustar。判定:三種 signature 都認得,方向正確。_is_safe_entry_path()(:325-347):空名、絕對路徑(含 Windows C:/ 與 UNC //)、正規化後仍以 .. 開頭、帶 NUL 的路徑。NUL 那一項特別值得記:某些解壓器會在 NUL 處截斷,造成「檢查的字串」與「實際寫出的路徑」不一致——這是少見但真實的繞過手法,這裡有擋。正規化前先把反斜線折成斜線(a\..\..\etc 這種寫法擋得住)。判定:四種都對,沒有漏。_assert_entry_safe()(:274-277)。這是 path traversal 的第二條路:路徑本身乾淨,但解壓後跟著連結寫出去就能逸出。_EntryInfo.is_link 由 member.issym() or member.islnk() 填(:266)。zip 這邊沒有對應檢查,檔頭註解(:86-88)自己說明了:zip 理論上能存 symlink(靠外部屬性),但標準庫的 ZipInfo 不直接暴露,且代理程式端解壓器另有防護。判定:tar 擋滿;zip 是已知且有意識的缺口,已自陳、且有第二道防線,不另計為發現——但若日後代理程式端防護有變動,這裡要重新評估。_MAX_MANIFEST_DEPTH = 1 的比對方式也對 — _is_manifest_path()(:308-318)用的是整段檔名相等而不是 endswith,所以 inspec.yml.bak、my-inspec.yml 這種騙不過去。深度上限 1 的理由(打包時多包一層目錄是常態,埋更深 cinc-auditor 找不到)站得住。
檢查順序刻意由便宜到昂貴 — 副檔名 → 大小 → magic → 開檔 → 逐 entry → 結構(validate() 的 docstring :123-124)。結構驗證刻意放最後(:167-170),理由寫明:安全性優先於「是不是一份 profile」,惡意檔要先被擋掉,不該因為「剛好也沒有 inspec.yml」而回一個誤導的結構錯誤。判定:順序正確,理由站得住。
大小上限是邊讀邊斷、不是先讀完再看 — _read_all_with_limit()(:194-215):total > self._max_bytes 在迴圈裡判,超限立刻丟。判定:正確(先整包讀進來再看大小等於沒有上限,50MB 的宣告擋不住 5GB 的實際上傳)。另外 Flask 層還有一道 MAX_CONTENT_LENGTH(主專案 core/app_factory.py:104,預設 50MB),在 routing 之前就用 Content-Length 擋掉,是第二道。
卡片寫的是:「15 條 profile route 只掛 require_license(沒有一條掛 detection-profile.* 四顆能力點——與 FR-098 第 80 項/FR-101 同款『宣告了不守』,確認後標同一產品決策題)」。
實查不成立。 逐支列出 detection_profile_route.py 的全部 18 支方法與它們的守門:
| 行 | 方法 | 能力點 |
|---|---|---|
| 135 | POST 列表查詢 | (無,讀取類) |
| 163 | GET 下拉選單 | (無,讀取類) |
| 184 | POST 新增基準 | _profile_create |
| 211 | GET 單筆詳情 | (無,讀取類) |
| 230 | PUT 編輯基本資料 | _profile_update |
| 258 | DELETE 刪除基準 | _profile_delete |
| 274 | GET 版本列表 | (無,讀取類) |
| 303 | GET 使用狀況 | (無,讀取類) |
| 328 | GET 版本使用狀況 | (無,讀取類) |
| 359 | PATCH 就地換來源 | _profile_create |
| 392 | DELETE 刪除某一版 | _profile_delete |
| 410 | POST 版更 | _profile_create |
| 439 | POST fork | _profile_create |
| 460 | POST 複製到另一工具 | _profile_create |
| 484 | PUT 停用 | _profile_delete |
| 507 | GET 控制項列表 | (無,讀取類) |
| 527 | GET 抽取狀態 | (無,讀取類) |
| 542 | POST 重新抽取 | _profile_create |
18 支裡 10 支掛了能力點,而且掛的正好是全部 10 支寫入類;8 支不掛的全是讀取類(GET,加上那支查詢用的 POST)。 這是合理設計,不是缺口。
而且不是最近才補的。 git show 8832c14(2026-08-31 套件抽出當下)與 git show a6475b4(開卡那天)兩個版本都各有 10 處 @_profile_——開卡時就已經掛好了,卡片的前提從寫下的那一刻就是錯的。中間 1461aea(09-13)只是把能力點名字從寫死字串改成從 config 取,掛載位置一支沒動。
讀取類不掛能力點是不是問題? 不是,理由有二:① 讀取本來就受 require_license 管(沒買這個模組的租戶進不來);② 能看到什麼由資料庫的租戶隔離與服務層的 or_(scope=='SYSTEM', tenant_id==ctx.tenant_id) 決定(detection_profile_service.py:26 註解寫明「RLS 只是兜底」),不是靠能力點。判定:④ 的這一半沒有問題,卡片標的「宣告了不守」與 FR-098 第 80 項的類比不成立,不應登記為同題。
⚠️ ④ 的另一半(公版 vs 租戶 scope、fork/copy_to_tool 的 tenant_id 來源)落在 D2-2 範圍(detection_profile_service.py 1,133 行),本棒沒掃。只順手記下一個錨點供 D2-2 用:新增基準的 serializer(api/serializers/detection_profile.py:216)scope 預設 TENANT、註解寫「只有平台管理員送得動 SYSTEM(service guard 擋,非此處)」,而分享給子樹那支(:250)用 validate.OneOf(["TENANT", "SHARED"]) 把 SYSTEM 排除在值域外——兩層寫法不一致(一個靠 service 擋、一個靠 schema 擋),D2-2 要驗第一層真的擋得住。
卡片問「DetectionProfileVersionSourceRoute(:337-377)是 patch 不是下載?——確認這支到底做什麼」。
確認了:這支不是下載。 它是「就地修正某一版的來源」——把某一版的內容換成新上傳的檔案或新網址,版號不變,抽取狀態回落 pending 並自動排重抽(docstring :339-345)。掛 _profile_create 而非 _profile_update,docstring 解釋了理由:它產生的是「這一版的內容」,與版更、重抽同一組權限語意;update 對的是主檔的名稱/分類編輯。判定:權限語意的選擇有明確理由,站得住。
⚠️ 「真正的下載在哪、_read_source_bytes(file_id) 用數字 id 查 upload_files 有沒有驗歸屬」這一半在 detection_profile_service.py,屬 D2-2 範圍,本棒沒掃。 留給 D2-2。
卡片要 D2-1 的 runner 順手答一句「有沒有被 runtime import」。
實查:零命中。 在套件內全域 grep profiles.tools / profiles/tools / from jedi_detection.profiles,扣掉那七支自己之間的相互 import,沒有任何 runtime 程式碼 import 它們。它們是把政府基準文件轉成 InSpec profile 的離線開發工具,跑在開發者自己機器上、不隨服務執行。判定:不構成攻擊面,與首腦 09-16 剔除它們的判斷一致。
detection_profile_ref.py(75 行)查證 — ✅ 沒問題這支只有兩個函式加一個 payload 組裝。查證三點:
profile: 區分「庫內參照」與「使用者手填」是對的(:19)。檔頭的理由站得住:手填值理論上可以長得像 UUID(低機率但非零),靠格式猜測是隱性契約;前綴則零歧義。判定:正確。extract_profile_uid() 對非字串回空字串而不是拋例外(:36-37 的 isinstance 檢查)。前端送來的值型別不可控,這個寫法讓呼叫端可以直接 if uid:。判定:容錯方向正確。PROFILE_PARAMS_KEY = "_profile" 用 _ 前綴(:23),沿用 _credentials / _source_file 慣例,讓既有剝除機制把它擋在畫面與稽核記錄之外。判定:與既有慣例一致。 ⚠️ 不過 D3-2 已經查出綁定寫入那條路沒有做剝除(detection_job_binding_handler.py:213,當時判定「不是漏洞但建議補」),所以這個 _ 前綴的保護在那條路上是打折的——兩件事是同一個題目,修 D3-2 那條時一併處理即可,本棒不另計。分兩層講:
「報出來的這條存在嗎」——可信度高。 F1 我不只開檔核對,還實測量出了數字(49.7MB/59.5 萬 entry → +360MB,CPython 3.11.9)。三位檢查員從三個不同角度獨立去翻 CPython 原始碼,都定位到同一個函式,三票全過。攻擊路徑的每一環我都確認過:四道 entry 檢查會放行合法檔名、結構檢查放一個 inspec.yml 就過、四支上傳端點吃同一支驗證器。
「只有這條嗎」——可信度中等,有三個已知限制。
low,只有一位研究員讀一遍,不是多位分工交叉讀。低強度的設計目的是快速篩,不是窮盡。git show 查了兩個歷史版本交叉確認,但仍屬單人判斷。verification.status 為 verified:三位檢查員對 2 條候選各投一票,6 票全數投出,沒有漏投,票數由工具自己的程式碼統計、不是任何 agent 自報。
研究員提報:前端可以塞 _ 開頭的內部欄位進任務參數,一路流到代理程式讀取檔案時用的允許清單。
這條整條都落在本棒範圍外(detection_job_binding_handler.py、detection_orchestration_service.py、agent_file_access_service.py,分屬 D3-2/D3-4a 與 FR-077 R2b)。⚠️ 越界(屬 D3-2 範圍)——而且 D3-2 那棒已經報過同一條並判定不成立(見 scan-D3-2-job-binding.md 末段),這次是第二次撞到同一個題目。
三位檢查員 1:2 否決,理由與 D3-2 當時一致:注入機制屬實,但攻擊者做完跟做之前的權限一模一樣(操作者本來就是專案管理者,本來就有那些檔案的存取權),且取檔跑在該代理程式自己的租戶脈絡下,跨租戶拿不到東西。判定:不計為發現,總表不加號。
這也是「低強度工具會被鄰居吸走」的又一次實證——範圍只有 2 個檔,研究員仍跑去讀了三個套件外的檔案,並把那邊的問題當成本棒發現報上來。
| 項目 | 數字 |
|---|---|
| run ID | wf_2d7eaf32-309 |
| 掃描 commit | 955e40964baa3cfc0e8766078db7d65075d3c50b(工作區乾淨) |
| 範圍 | 2 檔 422 行 |
| 強度 | low(一位研究員 + 三票面板) |
| 研究員 | 派 1 位、回 1 位、零重試 |
| 面板 | 2 條候選 × 3 位檢查員 = 6 票,全數投出 |
| 總耗時 | 約 136 分鐘(14:43 啟動 → 16:59 報告產出) |
| 候選 → 成立 | 2 → 1(另 1 條越界且被否決) |
| 驗證輪數 | 1 |
| 驗證章 | verified |
| 工具原始產物 | jedi-detection/CLAUDE-SECURITY-20260919-064334/(不入版控) |
這一棒是「先切小再跑」的第四次實證,也是最極端的一次。 D2-1 原本 5 檔 1,480 行,連跑六位研究員全部被砍、耗掉 2 小時 53 分零產出;切成 422 行之後,一位研究員零重試跑完。
⚠️ 但要記一筆:切小不是免費的。 422 行只跑出 1 條發現,而報告裡大部分的「沒問題」結論是人工查的——工具在這個尺寸下的產出密度很低。這與 D3-4b 的觀察(同檔第二次掃淨新增掉到 0)指向同一件事:低強度工具的邊際產出遞減得很快,切棒真正在做的是保證它跑得完,不是提高它找得到的量。人工查證那一端的比重會愈來愈高。
另記一筆診斷更正。 D2-1 六次失敗,我第一次回報時把死因歸給「工具判定思考停滯」,那個歸因不準確。實查 transcript 後發現六支裡有五支的最長思考卡在同一個數字 192 秒(3 分 12 秒),同一秒數重複五次代表那是一道固定的切斷線,不是模型自己想太久。首腦已採納此觀察記入 STATE。