---
title: O3b 檢查結果：Excel 內容解析
---

# O3b 檢查結果：Excel 內容解析

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

---

## 🔴 一句話結論

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

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

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

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

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

---

## 這一棒在檢查什麼

使用者傳上來的 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 密鑰與雲端權杖加密金鑰）修掉，均 ✅ 已修。

## 找到什麼：2 個問題

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

---

## 詳細說明

### 問題 1：版本號那一格可以讓網站卡死（中，本棒新找到）

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

問題在兩件事湊在一起：

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

實際的程式是這樣：

```python
# 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，記憶體就被吃光了。

```python
# 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` 改過去不會少功能、專案裡已有可重用的解壓檢查。

---

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

| 派工時問的 | 答案 | 依據 |
|---|---|---|
| ① `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` |

---

## 範圍外：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 已經指出過那條串連：拿資料庫密碼撈出客戶的雲端硬碟權杖，再用加密金鑰解開，就能直接讀客戶雲端硬碟裡的檔案。**本棒再次確認這四把都沒換**，排優先順序時值得重看一次。

---

## 可信度

**這兩條我自己開檔核對過，是事實陳述不是推論**：

- **問題 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` 裡，**我沒有重做這個比對**——但它們全部落在既有工單內，不影響本棒結論。

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

---

## 執行概況

| 項目 | 內容 |
|---|---|
| 掃描範圍 | `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（版本號卡死）與雲端硬碟加密金鑰。依工具規則，非全票通過的發現可信度上限為「中」。

---

## 對 FR-113 全案的意義

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

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

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