---
title: O4 檢查結果：Word 上傳與解析全鏈
---

# O4 檢查結果：Word 上傳與解析全鏈

> 檢查日期 2026-09-21｜耗時 2 小時 0 分｜對應卡片 CM-2004｜只掃不修

---

## 🔴 一句話結論

**派工時預判的「Word 很可能照抄 Excel 的毛病」成立，而且比 Excel 那條更寬。**
Word 匯入有三條去路，**兩條有權限檢查、第三條（蓋掉既有合規範本）完全沒有**——任何登入者，連唯讀稽核員都算，拿到別人的範本編號就能把整份範本清空重寫，而且**動手前還能先把那份範本的內容讀出來**。另外還有一條 Excel 那邊已經抓到、Word 這邊同樣中招的壓縮炸彈，以及一條全新的：**匯出的 Excel 會把使用者寫的字當成公式執行**。

---

## 這一棒在檢查什麼

「Word 匯入」是使用者上傳一份 .docx，系統把裡面寫的控制項實作內容解析出來，變成系統安全計畫（SSP）資料的整條路。它有三個去向：

1. **專案的系統安全計畫**——寫進某個稽核專案自己的資料
2. **建一個新的合規範本**（系統裡叫「資源庫」，是原廠或各公司預先寫好的控制項範本）
3. **蓋掉一個既有的合規範本**

另外還有一條單獨的路：「只匯入控制項實作」，走 Excel 檔，有自己的上傳與匯出入口。

這一棒檢查 9 支檔、1,950 行——**誰能用這些功能、上傳的東西怎麼被處理、匯出的東西安不安全**。純粹的 Word 內容抽取器（`domain/oscal/parser/` 那 7 支）不在本棒，在 O7a。兩條匯入線共用的接縫檔 `_common.py` 也不在本棒，在 O9。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 問題 1（Word 匯入蓋掉既有範本無權限檢查）＝M11 第 3 條，✅ 已修（CM-2178，commit `abf1cf8a4`，1.21.0 出貨）。
> - 問題 2（匯出 Excel 公式）＝M11 第 19 條，✅ 已修（CM-2055，commit `f85a82edb`，1.21.0 出貨）。
> - 問題 3（Word 壓縮炸彈）＝M11 第 24 條，✅ 已修（CM-2181，commit `48638cbdb`／套件 `2cb9d2cd`，1.21.0 出貨）。
> - 問題 4（每套安裝同一組管理員密碼）：🚫 裁定不修（M17 第 7 條）。

## 找到什麼：4 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 建議 |
|---|---|---|---|---|---|---|
| 1 | 🔴 **高** | **Word 匯入蓋掉既有合規範本那條路，從頭到尾沒檢查你是誰**——只確認「這個範本存在嗎」，不問「你有沒有資格改它」 | 任何登入者（含唯讀稽核員）可以把**別家公司或原廠公版**的合規範本整份清空重寫，底下的控制項實作、元件、人員、盤點項目連鎖刪除。**動手前還能先讀**：預覽畫面會把那份範本現有的內容一起回傳 | ① 任何有效登入帳號（**不需要任何功能權限、不需要是任何專案的成員**）<br>② 知道目標範本的編號——列表端點也只驗登入，撈得到 | `app/oscal/service/ssp_docx_import_app_service.py:165`（上傳時）<br>同檔 `:335, :361`（確認時） | 補上與手動路徑同一道門 |
| 2 | 🟡 中 | **匯出的 Excel 會把使用者寫的字當公式執行**——控制項現況描述若以 `=` 開頭，寫進 Excel 時會變成活的公式 | 下載這份 Excel 的同事打開檔案，Excel 會去跑那段公式。可以是把檔案內容偷傳出去，也可以是在對方電腦上執行指令 | ① 攻擊者能寫控制項現況描述（專案負責人，**或透過問題 1 的無權限路徑**）<br>② 有人匯出並用 Excel 開啟<br>③ 對方按下 Excel 的「啟用外部內容」提示 | `app/oscal/service/ssp_control_impl_import_service.py:139`（及 `:163`） | 輸出前把開頭的 `= + - @` 跳脫掉 |
| 3 | 🟡 中 | **上傳的 Word 檔只檢查壓縮後大小、不檢查解壓後大小**（壓縮炸彈） | 一個 15MB 的檔案解開後可以是好幾 GB，把處理這個請求的伺服器程序吃光記憶體打掛。反覆送幾次就能讓整個系統持續無法使用 | 任何有效登入帳號 | `app/oscal/service/ssp_docx_import_app_service.py:759`（限制只在 `:131`，20MB 壓縮後） | 解析前先查解壓後總大小與膨脹比 |
| 4 | 🟡 中<br>⚠️ 範圍外 | **每套安裝的最高管理員密碼都一樣**（密碼雜湊寫死在安裝腳本裡） | 任何一套安裝的檔案外流，推出那一組密碼後，可以登入**所有沒改過密碼的客戶系統**，拿到繞過客戶隔離的最高權限 | ① 拿得到程式碼倉庫或任何一份安裝包<br>② 推出明文密碼<br>③ 目標系統裝好後沒改過密碼 | `scripts/init/06-admin.sql:41-42` | **已知項，O1 已報過** |

---

## 詳細說明

### 問題 1：蓋掉別人的合規範本，不需要任何權限（唯一的「高」）

**白話**：Word 匯入在上傳那一刻會先問「你要寫到哪裡去？」，然後分三條路走。**三條路的把關鬆緊完全不同**：

```python
# app/oscal/service/ssp_docx_import_app_service.py:143-167
if source_type == "ssp":
    # 路 1：寫進專案的系統安全計畫
    self._require_project_manager(source_uid)          # ✅ 要是該專案的負責人
elif is_preselect_new:
    # 路 2：建一個新的合規範本
    if not viewer_has_capability("module-frame.create"):
        raise ForbiddenError(...)                      # ✅ 要有「建立範本」的權限
else:
    # 路 3：蓋掉一個既有的合規範本
    self._resource_library.get_resource_library(source_uid)   # ❌ 只確認「它存在」
```

第三行那個 `get_resource_library`，**首腦開檔確認過它整支只做一件事**：查不到就報「找不到」，查得到就把資料回傳。**沒有任何一行在問「你是誰、你有沒有資格改它」**：

```python
# app/oscal/service/resource_library_app_service.py:205-216
def get_resource_library(self, uid: str, locale=None) -> dict:
    row = self._rl_query.get_resource_library_row(uid, locale=locale)
    if row is None:
        raise NotFound(...)        # ← 只有這一個判斷
    ...
    return dto                     # ← 沒有任何權限檢查
```

而**改同一份資料的正規入口是有要求權限的**——首腦開檔核對過手動建立／複製範本那三支端點，全部掛著 `@require_capability`：

```
api/module_frame/routes/module_frame_route.py:56  @require_capability("module-frame.create")
api/module_frame/routes/module_frame_route.py:72  @require_capability("module-frame.update")
api/module_frame/routes/module_frame_route.py:95  @require_capability("module-frame.create")
```

**同一份資料、同一種破壞力，走正門要權限、走 Word 匯入不用。**

**確認階段有第二個更麻煩的地方。** 確認匯入時，系統允許使用者**在確認的當下再指定一次目標編號**：

```python
# app/oscal/service/ssp_docx_import_app_service.py:335
effective_source_uid = job.source_uid or payload.get("source_uid")
```

意思是：上傳時可以什麼都不填（走路 2「建新範本」，只需要「建立範本」權限），到了確認那一步再塞一個別人的範本編號進去。**上傳時通過的是「建立」的門，實際做的卻是「覆寫別人」的事。**

**破壞到什麼程度？** 覆寫走的是「先清空、再重建」：

```python
# 同檔 :378-387
self._oscal_io.clear_ssp_body(template_ssp_id)   # ← 整份清掉
counts = self._oscal_io.import_ssp(merged, ...)  # ← 換成攻擊者的內容
```

`clear_ssp_body` 會連鎖刪掉控制項實作、實作敘述、對應元件、元件本身、繼承授權、盤點項目、人員與角色。**攻擊者只要上傳一份近乎空白的合法 .docx，那份範本就等於被清空了。**

**還能先讀再毀。** 上傳完成後的預覽端點（`GET /ssp-docx-import/<編號>`）會把解析結果對著目標範本的現況做比對，**順便把那份範本現有的實作描述一起回傳**。換句話說，這條路**同時是讀取漏洞**——正常要看那份範本的內容是需要「讀取範本」權限的。

**攻擊步驟**（不需要任何特殊工具）：

1. 用任何帳號登入
2. 呼叫範本列表端點撈出所有範本編號（**這支也只驗登入**）
3. 上傳一份隨便的 .docx，指定 `source_type=module_frame`、`source_uid=<別人的範本編號>`
4. 讀預覽——對方範本的內容就出來了
5. 送出確認，`decisions` 給空陣列——對方範本被清空並換成你的內容

**為什麼底下沒有第二道關卡擋住**：存放這些資料的 `oscal.*` 那幾張表**沒有設資料庫層的客戶隔離保護**（只有解析工作那幾張表有）。所以一旦範本編號讀得到，寫入就不會再被攔一次。而「讀得到」的範圍包含**原廠公版**（`scope='SYSTEM'`）與**分享出來的範本**（`scope='SHARED'`），這兩類跨客戶都看得到。

> **這與 O3a（Excel）是同一個病。** O3a 抓到 Excel 匯入的同一條去路也沒有權限檢查（`ssp_excel_import_app_service.py` 的 `_verify_source_exists`，module_frame 分支）。首腦開檔確認過那支的註解寫著「權限由 RBAC middleware 處理」——**但那層 middleware 並不存在於這條路上**，路由只有 `@jwt_required()`。兩條線應該在同一次修正裡一起補，不要只補一邊。

---

### 問題 2：匯出的 Excel 把使用者寫的字當公式執行

**白話**：系統匯出控制項現況的 Excel 時，直接把資料庫裡存的文字填進儲存格：

```python
# app/oscal/service/ssp_control_impl_import_service.py:139
ws.cell(row=row_num, column=5, value=ov.get("desc") or "")               # E
# 同檔 :163
ws.cell(row=row_num, column=8, value=o.get("desc") or "")                # H
```

產生 Excel 的那套程式庫（openpyxl）有一個行為：**填進去的文字只要以 `=` 開頭，它就會判定那是公式**，寫進檔案時標成公式而不是文字。於是「使用者打的一段描述」在別人的 Excel 裡變成一行會跑的程式。

**出事的樣子**：稽核員把某個控制項的現況描述寫成 `=cmd|'/c ...'!A1`，之後複核人員匯出這份 Excel、用 Excel 打開，Excel 會跳出「要不要啟用外部內容」，一旦按下去，那段指令就在複核人員的電腦上執行了。也可以改成把檔案內容偷偷送到外部網址。

**為什麼是「中」不是「高」**：Excel 從 2017 年起預設關閉這類外部連結，要使用者自己按下同意。但「同意」這個動作在企業內部的例行作業中很常被按掉，而且這份檔案是**系統自己產生、寄給同事的**，收件人的戒心比收到陌生附件低得多。

**另外一個連動**：問題 1 讓任何登入者都能寫入範本內容，等於也把這條路的「要先有寫入權」前提降到最低。**兩條串起來，一個沒有任何權限的帳號可以間接在複核人員的電腦上放公式。**

---

### 問題 3：上傳的 Word 檔沒有解壓後大小限制（壓縮炸彈）

**白話**：系統對上傳的 .docx 只檢查**壓縮後**大小不超過 20MB：

```python
# app/oscal/service/ssp_docx_import_app_service.py:50, 131
_DOCX_MAX_SIZE = 20 * 1024 * 1024  # 20MB
if file_size > _DOCX_MAX_SIZE:
    raise BadRequestError(...)
```

但 .docx 本質是一個壓縮檔。裡面的 `word/document.xml` 如果是大量重複的內容，**壓縮比可以到一千比一**——15MB 的檔案解開來是十幾 GB。系統接下來會把整份解開並全部載進記憶體解析（`:759` 的 `_Doc(file_path)`），沒有任何一道關卡檢查解開後有多大。

**出事的樣子**：處理這個請求的伺服器程序記憶體一路往上爬，被作業系統強制終止。**同一個程序上其他人正在處理的請求也一起陪葬。** 每分鐘送幾次，系統就持續不可用。

**這條與 O3a 同源**：O3a 已經在 Excel 那條抓到一模一樣的問題（那邊限制是 10MB）。兩邊的修法一樣：解析前先用壓縮檔工具讀出「解開後總大小」，超過合理上限就直接拒收。

---

### 問題 4：每套安裝的最高管理員密碼都一樣（範圍外，已知）

安裝腳本裡把最高管理員 `admin` 的密碼雜湊寫成固定值：

```sql
-- scripts/init/06-admin.sql:41-42
c_password CONSTANT text := '$2b$12$yQu0...';
c_salt     CONSTANT text := '$2b$12$yQu0...';
```

意思是**每一個客戶裝出來的系統，最高管理員的密碼都是同一組**。這個帳號的權限會繞過「每個客戶只看自己資料」的隔離機制，而且設計上不可刪除、不可停用、不可降級。

**這條 O1 已經報過**，本棒的工具沿相依關係又撿到一次。**不另計、不另開卡**，列在這裡只是說明本棒的四條發現裡有一條是重複的。

---

## 派工時的預判，哪些成立哪些不成立

卡片列了六個「重點看什麼」。逐條交代：

| 派工時的預判 | 結果 |
|---|---|
| 逐支公開方法對照守門，注意「守門藏在私有方法裡」 | ✅ **成立，而且是本棒的高風險**。三條去路裡兩條有守、一條沒有，與 Excel 同型 |
| Word 有沒有對應 Excel 的檔案大小上限 | ⚠️ **有，但不夠**。`_DOCX_MAX_SIZE` 是 20MB，管的是壓縮後大小，擋不住壓縮炸彈 |
| `source_uid` 由使用者送上來，有沒有檢查是不是他自己的 | ✅ **成立，而且比預期糟**。不只上傳時沒檢查，**確認時還允許再換一個目標編號** |
| 「只匯入控制項實作」那條獨立入口的守門跟主匯入是不是同一套 | ❌ **這條反而是好的**。開檔核對過：匯出／驗證／重驗要求 `require_participant`、確認匯入要求 `require_manager`，四支公開方法**全部有守**。這條入口的問題不在權限，在匯出內容的跳脫（問題 2） |
| `party_match_enrich.py`：人名對不到時怎麼辦、能不能把別家公司的人對進來 | ⭕ **工具沒報，首腦也未逐行復核**。這支只有 78 行且是預覽用途不落資料，風險相對低，但**本棒未能給出「已確認安全」的結論**——若要確認，需另行指定 |
| 檔名處理與暫存檔殘留 | ⭕ **工具沒報**。開檔時看到暫存檔在解析失敗路徑上有 `_safe_unlink` 清理，但**未做完整復核** |

---

## 執行概況

| 項目 | 內容 |
|---|---|
| 掃描範圍 | 9 支檔／1,950 行（Word 上傳解析鏈，主專案側） |
| 工具 | Claude Code 官方 `claude-security` plugin v0.11.0 |
| 設定 | `--effort low`（單一研究員掃全範圍＋三人面板）／`focus=attack-surface`（跳過測試碼與第三方套件） |
| 掃描起點 | commit `7ce62104`（branch `feature/review`） |
| 派出／回報 agent | **14 派 14 回，零失敗、零重試**（`agents_error: 0`） |
| 候選發現 | 6 條，去重後 4 條 |
| 面板 | ✅ **完整跑完**。4 條候選 **12 票全投、零漏投、全部 3:0** |
| 驗證章 | `verified`（工具自行依票數計算，非人工填寫） |
| 耗時 | 2 小時 0 分（7,194 秒） |
| 首腦復核 | 高風險那條**逐行開檔核對過**（路由守門、`get_resource_library` 全文、手動路徑的 `@require_capability`、確認階段的覆寫邏輯），其餘三條核對過程式碼位置與行號 |

---

## 建議的修正方向

**問題 1（高）優先，而且要和 O3a 的 Excel 那條一起修**——兩條是同一個病的兩個入口，只補一邊等於沒補：

1. Word 與 Excel 匯入「覆寫既有範本」那條分支，補上與手動路徑同一道門（`module-frame.update`）
2. **確認階段要重新檢查一次**，不要相信上傳時的判定
3. 拿掉「確認時可以再指定目標編號」這個設計，或者至少驗證新指定的目標是呼叫者有權寫入的

**問題 3（中）也建議與 Excel 那條一起修**——同一個解法，兩邊各加一道解壓後大小檢查。

**問題 2（中）** 是獨立的一條，修法是輸出 Excel 前把開頭的 `= + - @ Tab 換行` 跳脫掉；同樣的處理要套到共用的範本匯出程式（`app/module_frame/service/module_frame_template_import_service.py`）。

**問題 4** 已有既有工單涵蓋，不另開。

---

## 這一棒沒有涵蓋的

- **Word 內容抽取器本身**（`domain/oscal/parser/` 7 支）——在 O7a，風險型態是「惡意檔案」
- **兩條匯入線共用的接縫檔 `_common.py`**（620 行）——在 O9
- **人員對帳（`party_match_enrich.py`）與暫存檔清理**——工具沒報，首腦也未逐行復核，見上表
- 這是 `low` 檔次的一輪掃描：一個研究員完整看過整個範圍，**不是完整體檢**
