O3b 檢查結果:Excel 內容解析

O3b 檢查結果:Excel 內容解析

檢查日期 2026-09-21|掃描耗時 1 小時 44 分|對應卡片 CM-2010


§1

🔴 一句話結論

掃完了,範圍內找到 2 個中風險,最嚴重的是「一個小檔可以把整個產品打停」——而且有兩種不同的打法。

第一種是派工時就點名的:Excel 檔讀進來時整份塞進記憶體,一個壓縮過的小檔解開後可以吃光記憶體,落地版容器被系統殺掉、產品停擺。這條 O3a 已經順資料流撞到並登記在總表第 124 項,本棒是在它自己的範圍裡把它完整看過一遍,不另計。

第二種是本棒新找到的,比第一種更便宜:檔案裡「說明」分頁 B1 那格版本號,系統拿一段比對規則去讀它,但那格內容沒有長度上限、比對規則也沒寫好——填一百萬個數字進去(壓縮後只有幾 KB),系統會卡在那裡算到逾時被砍。四個這種請求就能讓整個網站沒反應,而且這一步跑在版本檢查之前,所以連一份合法的範本都不需要。

派工時點名要追的另外兩條,答案是「沒有」:公式注入這半邊確實沒擋(進來的字元原樣存下),但那只是半條路,另外半邊在匯出線(O2/O4 已登記);組裝資料時沒有拿檔案裡的編號當系統內編號用。

⚠️ 另外,掃描工具的「找寫死密碼」那一輪撈到 4 條範圍外的憑證外洩,全部是既有工單已涵蓋的老問題,不另計(對照見文末)。


§2

這一棒在檢查什麼

使用者傳上來的 Excel 檔,要把裡面九張工作表的每一格讀出來、轉成系統看得懂的資料。這一棒掃的就是**「讀」這個動作本身**——一份動過手腳的 Excel 傳上來,能對系統做什麼。共 7 支檔、1,047 行。

不含「誰能傳、誰能按確認」那條權限路(那是 O3a,已掃完),也不含兩條匯入線共用的接縫檔(那是 O9)。

本棒的題目從頭到尾是惡意檔案,不是權限——解析器是純函式,不碰資料庫也不碰登入狀態。


現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • 問題 1(版本號比對卡死)=M11 第 25 條,✅ 已修(CM-2181,commit 48638cbdb,1.21.0 出貨)。
  • 問題 2(壓縮炸彈)=M11 第 23 條,✅ 已修(CM-2181,1.21.0 出貨)。
  • 範圍外四條憑證舊帳:原卡 CM-1629/CM-1607/CM-1631 已作廢,實際由 CM-2049(資料庫密碼)、CM-2048(簽章金鑰)、CM-2051(Google 密鑰與雲端權杖加密金鑰)修掉,均 ✅ 已修。
§3

找到什麼:2 個問題

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡
1 🟡 中 一格版本號沒有長度上限,比對規則又沒寫好,遇到一長串數字會陷入指數級的重試 一個請求就佔住一個工作程序直到 120 秒逾時被砍。預設只有四個工作程序,四個這種請求整個網站就沒反應,每兩分鐘重打一次就能一直壓著 ① 任何一個登入帳號
② 一份有「說明」分頁、B1 格填一百萬個數字(不含小數點)的 Excel
③ 不需要合法的範本版本——這一步跑在版本檢查之前
app/oscal/service/excel_parser/parser.py:152(_extract_semver)
觸發點 :143
2 🟡 中 上傳只擋壓縮後的大小,讀檔時卻整份塞進記憶體(⚠️ O3a 已登記=總表第 124 項,本棒不另計) 一個 10MB 以內、解壓後膨脹到幾 GB 的 Excel 可以吃光容器記憶體。落地版是單一容器,被系統殺掉就是整個產品停擺,而且可以重複打,自動重啟也救不回來 ① 任何一個登入帳號(走 module_frame 那條連範本權限都不用)
② 檔名以 .xlsx 結尾、壓縮後 10MB 以內——這條路上沒有任何檔頭、解壓後大小、壓縮比或檔案筆數的檢查
app/oscal/service/excel_parser/parser.py:42(ExcelParser.parse)

§4

詳細說明

問題 1:版本號那一格可以讓網站卡死(中,本棒新找到)

白話:系統要知道使用者傳的是第幾版的範本,作法是去讀「說明」分頁第 1 列 B 欄那一格,用一段規則把 v4.0.0 這種樣子的字撈出來。

問題在兩件事湊在一起:

  1. 那一格的內容沒有長度上限——str(cell_val) 直接整個拿去比對,使用者填多長就多長。
  2. 比對規則沒有綁定開頭——寫的是「找出任何位置的 v?數字.數字.數字」。遇到一長串只有數字、沒有小數點的字,比對引擎會從第 1 個字元開始貪心地往後掃、失敗,再從第 2 個字元開始掃、再失敗⋯⋯每個起點掃一遍。一百萬個數字就是一百萬次、每次掃一百萬格。

實際的程式是這樣:

# app/oscal/service/excel_parser/parser.py:140-153
cell_val = ws.cell(row=_INTRO_VERSION_ROW, column=_INTRO_VERSION_COL).value
if not cell_val:
    raise BadRequestError(...)
return _extract_semver(str(cell_val))       # ← 沒有長度上限

_SEMVER_PATTERN = _re.compile(r"v?\d+\.\d+\.\d+")   # ← 沒有綁定開頭

def _extract_semver(text: str) -> str:
    m = _SEMVER_PATTERN.search(text)
    return m.group(0) if m else text.strip()

攻擊怎麼進行:做一份最小的 Excel,只要有一張叫「說明」或「00_說明」的分頁,B1 格填一百萬個數字。壓縮後只有幾 KB,遠低於 10MB 的上限。傳到 /api/1.0/ssp-excel-imports/parse,開檔很快就過,讀到那一格就卡住。

最關鍵的一點:這一步跑在版本檢查(is_supported)之前,所以攻擊者連一份版本號合法的範本都不需要準備——隨便一個數字串就行。

⚠️ runner 開檔核對(事實陳述):version_check.py:35 裡的 _VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") 是綁定開頭與結尾的,它沒有這個問題。出問題的是 parser.py:151 這第二份、沒綁定的副本——同一件事在兩個地方各寫了一次,一份寫對、一份寫錯。

怎麼修:兩件事各做一半就夠——① 讀那一格時先截斷,str(cell_val)[:200];② 把規則的數字位數限制住,改成 v?\d{1,5}\.\d{1,5}\.\d{1,5}。更乾淨的作法是直接刪掉 parser.py 這一份,改用 version_check.py 裡已經寫對的那一份(符合 CLAUDE.md「先查既有能力、不要重複造輪子」)。


問題 2:壓縮炸彈——整份塞進記憶體(中,O3a 已登記,總表第 124 項)

白話:Excel 檔本質上是壓縮檔。系統在上傳時量的是壓縮後的大小(10MB 以內就放行),但讀檔時用的是「整份載入記憶體」模式。一份刻意做的檔案壓縮後 2MB、解開後幾 GB,記憶體就被吃光了。

# app/oscal/service/excel_parser/parser.py:41-42
# data_only=True:拿 Excel 開檔重算後的 cached value (VLOOKUP / INDEX-MATCH 結果)
wb = load_workbook(file_path, data_only=True, read_only=False)

read_only=False 就是「整份載入」;read_only=True 才是逐列串流讀取。

為什麼落地版特別嚴重:docker/production/docker-compose.yml 沒有給 guidant-api 設記憶體上限,而落地版是單一容器——被系統殺掉就是整個產品停擺。容器設定是自動重啟,但攻擊可以立刻再打一次。

⚠️ runner 開檔核對(事實陳述):_iter_data_rows(sheet_handlers.py:97-101)本來就是一列一列往下讀的,沒有任何地方需要隨機跳著存取儲存格。也就是說改成串流模式不會少任何功能。

怎麼修:兩層都補——① 呼叫 load_workbook 之前先用 zipfile.ZipFile 打開,檢查解壓後總大小、檔案筆數、每個檔案的壓縮比。專案裡已經有一模一樣的東西:core/plugins/detection.py 的 profile_extracted_max_mb / profile_max_entries / profile_max_ratio,直接重用、不要再寫第二套;② 改成 read_only=True。

與 O3a 的關係:O3a 順著資料流撞到這條並已登記(總表第 124 項)。本棒是在這支檔自己的範圍裡把它完整看過一遍,不另開項次,但補上了兩件 O3a 沒查的事實:read_only=True 改過去不會少功能、專案裡已有可重用的解壓檢查。


§5

派工時點名要追的四條,答案是什麼

派工時問的 答案 依據
① parser.py:42 整份載入記憶體 + data_only=True 讀快取值 ✅ 確實如此(=問題 2)。data_only=True 本身不是漏洞,它讀的是 Excel 自己算好存起來的結果,攻擊者能控制那個值,但那個值本來就是他填的 parser.py:41-42
② 公式注入——= + - @ 開頭有沒有中和掉 ❌ 沒有中和。sheet_handlers.py:64 的 _coerce_cell 只把數字轉成字串、把 None 保留下來,沒有碰開頭字元,=SUM(...) 原樣存進系統。但這只是半條路——要真的出事,還要匯出端也不擋。匯出那半邊 O2/O4 已經各報過一次(O2 是匯出 Word、O4 是匯出 Excel),所以整條路是通的,但缺口登記在匯出端、本棒不重複開項次 sheet_handlers.py:64-101
③ validators.py 只有 66 行、哪些欄位沒驗 🔵 不是缺口。它驗的是「必填欄有沒有填」這類業務規則,錯誤收進 validation_errors 讓使用者看得到哪一列有問題——這是使用體驗設計,不是安全守門。真正會出事的解析行為(大小、長度)不歸它管,所以「它很短」不代表有洞,代表守門本來就不在這裡 validators.py 全檔
④ v2_bundle.py 有沒有拿檔案裡的編號當系統內編號 🔵 沒有。組裝時 matched_party_uuid 一律寫 None、match_confidence 寫 0.0,留給後面的對帳程序去填。使用者決定不了要改到誰 v2_bundle.py:188,214

§6

範圍外:4 條憑證外洩,全部既有工單涵蓋,不另計

掃描工具在設定了 attack-surface 的情況下會附帶跑一輪「找寫死的密碼」,掃的是整個 repo 不限本棒範圍。撈到四條,全部是已知的老問題:

工具給的嚴重度 是什麼 對應既有工單
🔴 高 最高權限帳號 cmmgr 的資料庫密碼散在約 250 個受版控檔案裡,至今沒換過(與工作區 .env 現值一致) CM-1629(FR-081 I2 起,O5 已再次確認同源)
🟡 中 登入簽章金鑰寫在 30 個對話紀錄檔裡,至今沒換過 CM-1607
🟡 中 Google 雲端硬碟的應用程式密鑰寫在 12 個檔裡,至今沒換過 CM-1631
🟡 中 雲端硬碟權杖的加密金鑰寫在 3 個檔裡 CM-1631

⚠️ 其中兩條(登入簽章金鑰、雲端硬碟密鑰)工具明確查證過「現在的 .env 裡還是同一個值」——這不是歷史殘留,是現役憑證。O2 已經指出過那條串連:拿資料庫密碼撈出客戶的雲端硬碟權杖,再用加密金鑰解開,就能直接讀客戶雲端硬碟裡的檔案。本棒再次確認這四把都沒換,排優先順序時值得重看一次。


§7

可信度

這兩條我自己開檔核對過,是事實陳述不是推論:

  • 問題 1:parser.py:140-153 打開就是那樣寫的——str(cell_val) 沒有截斷、_SEMVER_PATTERN 沒有 ^$。而 version_check.py:35 的那一份有 ^$。兩份規則並存、一份寫錯,這是看著程式碼直接讀出來的。唯一沒做的是實際計時(本棒只掃不跑),所以「一百萬個數字要跑多久」是依規則形狀推算的,不是量出來的。三位審查員有兩位確認(2/3)。
  • 問題 2:parser.py:42 的 read_only=False 是字面寫著的;sheet_handlers.py:97-101 的 _iter_data_rows 是逐列讀,所以「改串流不會少功能」是看程式碼得出的。三位審查員全數確認(3/3),且 O3a 已獨立報過同一條。

工具給的、我沒有逐條開檔核對的:四條範圍外的憑證外洩。工具聲稱它做了「不印出內容的逐字比對」確認這些值仍在 .env 裡,我沒有重做這個比對——但它們全部落在既有工單內,不影響本棒結論。

這一棒沒有執行任何程式碼。所有結論都來自讀程式碼,沒有真的發動過攻擊,也沒有跑過概念驗證。


§8

執行概況

項目 內容
掃描範圍 app/oscal/service/excel_parser/ 七支檔
檔數/行數 7 檔/1,047 行(派工前實測,與卡片一致)
工具設定 effort=low、focus=attack-surface、mode=scan
掃描起點版本 dc6aef65(工作區有未提交異動,章上記為 -dirty)
派出/回報 agent 23 派 23 回,零失敗、零跳過、零空回報
研究員 2 派 2 回,零重試(上游看門狗誤殺沒有發生)
候選發現 8 條,去重後 7 條
面板 ✅ 完整跑完:21 票全投、零漏投。存活 6 條——4 條 3:0、2 條 2:1
章(verification.status) ✅ verified
耗時 1 小時 44 分
產物 CLAUDE-SECURITY-20260921-154709/(報告、JSONL、SARIF、版本章)

面板調整過一次嚴重度:登入簽章金鑰那條研究員報「高」,三位審查員投出「中、中、高」,工具依規則降為中。本報告照實記錄降級後的結果,不還原。

兩條 2:1 的是哪兩條:問題 1(版本號卡死)與雲端硬碟加密金鑰。依工具規則,非全票通過的發現可信度上限為「中」。


§9

對 FR-113 全案的意義

本棒是 Excel 這條線的最後一塊。 O3a(誰能傳、誰能確認)+ O3b(傳進來的檔怎麼讀)合起來,整條 Excel 匯入路徑掃完了。

兩棒的病不同形狀,值得記一筆:O3a 是本 arc 那條主線——「有人守了一半」,三條去路守了兩條。O3b 不是,解析器這裡沒有守門可言,問題是**「使用者塞多少、系統讀多少」:沒有長度上限、沒有解壓後大小上限。前面五棒累積的「權限守一半」判準在這一棒用不上**,是另一類題目。

唯一與前面接得上的線索是公式注入:進來這端不擋(本棒確認)、出去那端也不擋(O2/O4 已報),進出都沒擋才是完整一條路——這是本 arc 第二次出現「同一塊地要兩端一起看」的形狀(第一次是 O5 補上「讀」那一面)。修匯出端的跳脫處理時,可以順手考慮在匯入端也中和掉開頭字元,做成兩層防護。