⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
找到 1 個中度問題:一個很小、但刻意做壞的 tar.gz 壓縮檔,可以在 Agent「數檔案數」那一步就把記憶體吃光,讓 Agent 整支程式當掉(心跳、掃描、主控台全停)。三個獨立的檢查員全數認定成立。不會被偷資料、不會被執行程式,是「讓服務停擺」型的問題。
主系統派掃描任務時會附一份「檢測規則包」(CINC Auditor profile,本質是會被執行的 Ruby 程式)。Agent 要把它下載、核對指紋(sha256,就是檔案內容的「身分證號碼」,內容改一個字就對不上)、解壓到本機,再交給掃描工具。這一棒看這條鏈有沒有洞,以及全 Agent 唯一一份「安全解壓」共用程式(它同時服務源碼包、漏洞庫、升級包)。
範圍三個檔:guidant_agent/sync/app/profile_cache.py、guidant_agent/sync/app/profile_sync.py、guidant_agent/shared/archive_safety.py。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 急不急 |
|---|---|---|---|---|---|---|
| F1 | 解壓前「先數有幾個檔」這一步本身沒有上限,做壞的小壓縮檔可以讓這一步吃光記憶體 | Agent 整支程式被系統強制結束;同一個檔再丟一次就再當一次 | 能讓 Agent 解一個 tar 類壓縮檔的人:主控台有掃描或更新權限的登入者、或能替這台 Agent 的掃描任務附源碼包的主系統使用者。對規則包這條路:要先能在主系統放一份 sha256 對得上的惡意規則包(等於主系統已被打穿或上傳者本身惡意) | guidant_agent/shared/archive_safety.py:191 |
見下方 F1「怎麼修」 | 中:要先有登入權限,後果是停擺不是外洩;但共用層,一處修好四條路都受惠 |
白話:Agent 解壓前會先看「這包裡面有幾個檔、加起來多大」,超過上限就拒收——這是防「壓縮炸彈」(解開後暴增成幾百 GB 的檔)的設計。問題是 tar 格式要「整包讀過一遍」才知道有幾個檔,而 Python 的 getmembers() 讀的時候會把每一個檔的檔頭資訊都存進記憶體;另外 tar 有一種「長檔名」檔頭,檔頭本身宣告多大就讀多大進記憶體。這些都發生在「檢查上限」之前,所以上限擋不到。
出事會怎樣:攻擊者做一個不到 100 MB 的 tar.gz,裡面塞幾千萬個一模一樣的空檔頭(壓縮後每個只佔幾個 byte),或一個宣告好幾 GB 的長檔名檔頭。Agent 一讀就超過容器記憶體上限,被作業系統強制結束。心跳停、掃描停、主控台停,重丟一次再停一次。
要先有什麼才打得到:
profile_cache.py:339-342),所以要打到這裡,惡意檔必須就是主系統記錄的那一份——門檻最高。tarfile 原始碼得出,沒有實際跑。在哪裡:guidant_agent/shared/archive_safety.py:190-195(extract_archive 的 tar 分支:getmembers() 在第 191 行,check_archive_quota 在第 192 行之後)。zip 分支(:177-182)用 infolist() 讀的是檔尾的目錄區,成本跟檔案數成正比但不受「長檔名」放大,影響較小。
怎麼修(修另開卡):在 extract_archive 的 tar 分支,不要呼叫 archive.getmembers();改成 for member in archive: 逐一讀,邊讀邊累加檔數與宣告大小,一超過 max_files/max_bytes 就立刻 raise;並拒收 member.name 長度超過例如 4 KB 的項目。另把傳給 tarfile.open 的檔案物件包一層「讀超過 N bytes 就中止」的計數器,限制列舉階段解壓出來的總量。離線更新包的呼叫端(不在本棒範圍)應先驗簽章再解壓。
runner 開檔核對:已開 archive_safety.py:165-209 確認:tar 分支 getmembers() 確實在 check_archive_quota 之前,中間沒有任何計數或上限。成立。
| 卡片要點 | 人工結果 | 為什麼 |
|---|---|---|
archive_safety.py:130-146 缺 sha256 的兩種處置;profile 有沒有走「略過」;網址型來源會不會正好走進略過 |
**不成立(對 profile) | verify_checksum 的 required=True 會中止(:139-144)。profile 兩條入口都在下載前先擋缺 sha:派工路徑 profile_cache.py:159-164、心跳預拉路徑 profile_cache.py:285-287,下載後再以 required=True 驗一次(:341)。網址型 profile 在 resolve_profile_source 直接被當資料錯誤拒收(profile_cache.py:119-131 docstring 與型別檢查),所以不存在「網址型走進略過」。旁註**:「略過只記 log」那條路仍存在,是給源碼包用(required=False),不在本棒範圍,建議源碼包那棒確認主系統是否一定帶 sha。 |
:185-198 解壓的路徑逃逸怎麼擋;tar 與 zip 是否同一套;總大小/檔數上限怎麼算 |
**路徑逃逸:不成立;上限:部分成立(即 F1) | zip 用 ZipFile.extract(標準庫會去掉 .. 與開頭的 /);tar 用 extract(..., filter="data")(Python 3.11.4 以上的官方安全過濾,擋 ..、絕對路徑、逃逸 symlink、裝置檔)。兩種格式不是同一套程式碼,但各自走標準庫的安全機制。上限:解壓前加總「宣告」大小與檔數,解壓後 verify_extracted_size(:232-253)再量實際用量——但實際用量是全部解完才量**,所以「宣告說謊」的檔會先把磁碟寫到超量才被發現(上限是 _EXTRACTED_MAX_MB,有上界,不至於無限)。列舉階段沒上限就是 F1。 |
profile_cache.py:29-30 完成標記;cache 路徑由誰決定、uid 有沒有過濾;同一 uid 不同租戶會不會互相命中 |
不成立 | 路徑是 build_cache_dir(profile_cache.py:389-395)組的,uid 與 version 都經 _safe_path_segment(:497-505):只留英數與 -_.、去頭尾的 .,帶 ../ 的 uid 會被換成底線,逃不出 cache 根。Agent 是單租戶(一台 Agent 固定一個客戶,見 CLAUDE.md),沒有「不同租戶共用 cache」的情境。另外命中判斷要求完成標記裡的 sha256 跟這次派工給的一致(:463-477),就算 uid 撞到也拿不到不同內容。 |
profile_sync.py:162-168 回報的 sha 是自己算的還是抄主系統的;錯誤訊息會不會帶出本機路徑 |
**sha:抄主系統的(低風險、非漏洞);路徑外洩:部分成立(輕微) | _sha(profile)(profile_sync.py:202)是讀心跳給的值,不是本機重算。但本機在寫完成標記前已經用 required=True 對帳過(profile_cache.py:339-342),對不上就失敗、不會回報成功,所以回報「OK+sha X」時本機內容確實等於 X,回報的值並不失真。錯誤訊息:error = f"{exc}(...)"(:153)會把例外原文(截 500 字)寫進稽核 log 與回報列,例如 OSError 原文會帶本機 cache 路徑。這是送回自家主系統、不是給外人,影響很小;若要收緊,可對照規則「錯誤只回代碼」改成只回錯誤種類。本棒工具沒報,列為人工觀察,不算正式發現。** |
下載 GET /api/1.0/agents/files/<uid>:Agent 有沒有驗 uid 屬於這次任務,還是任何 uid 都拉 |
不成立(在 Agent 這側) | uid 只來自主系統下發的派工 _profile 或心跳清單,Agent 不接受本機使用者輸入的 uid,主控台沒有「任意 uid 下載」入口(本棒範圍內的呼叫者只有 profile_sync._sync_one 與 resolve_profile_source)。Agent 是拿主系統的檔回自己用,不是替別人轉檔,所以不構成「讀檔代理」。「主系統是否只允許這台 Agent 拉屬於它的檔」是主系統 agents/files 端點的權限檢查,不在本 repo。 |
| 解壓後交給 cinc-auditor 的目錄內容是否與驗過的那份一致 | 成立(一致) | 順序是下載 → sha256 對帳 → 解壓到暫存目錄 → 寫完成標記 → os.replace 原子換上(profile_cache.py:322-372),失敗就整包清掉;命中時再比一次標記 sha。交出去的是 resolve_profile_root(cache_dir),即同一個目錄。殘留風險:cache 目錄本身若被本機有寫入權限的人改,命中判斷只看標記不重算內容——這需要已能寫 Agent 資料目錄,屬本機權限問題,不另列。 |
(附帶)規則寫「cinc 規則包設定含樣板記號 <% 即拒,擋在 archive_safety.py 共用層」 |
規則與程式不符(建議另查) | 在 guidant_agent/shared/ 與 profile_cache.py 都找不到 <% 的檢查。可能實作在別處(例如 scan 模組),也可能沒做。本棒範圍外,不能下結論,建議首腦另開卡確認,或更正規則文字。 |
low 強度掃描:一位研究員讀完三個檔,沒有做威脅建模或廣度補掃。上面第 5 節由 runner 補了卡片列的六個重點,但工具本身有隨機性,沒報不等於沒有。另外 candidates: 1 代表只有一條候選送去投票,不是「三個檔只被看出一條就乾淨」,而是研究員只提出了這一條。| 項目 | 值 |
|---|---|
| run ID | wf_01029f1f-285 |
| 掃描版本 | 3813abd05232860faa658d681b37cb85a20168b8(branch ws-CM-3507-CM-3511,工作目錄乾淨) |
| 強度 | low(一位研究員+三位檢查員) |
| status | verified |
| reason_kind | null |
| candidates | 1 |
| panel_votes | 3(三個檢查員對 1 條發現各投一票,3 票全數投出,沒有漏投) |
| researchers_dispatched/returned | 1/1 |
| 耗時 | 約 36 分鐘(stamp duration_s: 2139) |
| 歸檔 | docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-155939-A4/(原目錄名與 A2 同秒撞名,加 -A4 後綴區分) |