---
title: O1 檢查結果：控制項實作與 SSP 權限判斷核心
---

# O1 檢查結果：控制項實作與 SSP 權限判斷核心

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

---

## 🔴 一句話結論

**找到兩個同一種形狀的權限漏洞：刪除文件時，系統檢查的是「你管不管網址上那份計畫」，但刪掉的是「你指定的那份文件」——兩者從來沒有互相核對過。** 任何員工只要自己開一個專案（開專案的人自動就是該專案負責人，不需要誰核准），就能刪掉**別家公司**的證據文件；其中一個還會連實體檔案一起銷毀。

另外**這棒要守護的那支權限判斷程式本身沒有被判定有問題**——漏洞不在警衛身上，在「問對了問題、卻問錯了對象」的呼叫端。

---

## 這一棒在檢查什麼

「控制項實作」是整個系統最核心的資料：每一條合規要求底下，「我們公司實際上怎麼做到這條」的文字記錄與佐證文件。這一棒檢查 6 支檔、1,943 行，包含：

- 這塊功能的網址入口、兩支資料格式定義、商業邏輯主程式、讀取查詢
- **全系統唯一一支判斷「你能不能看／能不能改這份系統安全計畫」的程式**（`common/authz/ssp.py`）

之所以把最後這支放進來，是因為它被 9 支商業邏輯檔呼叫共 54 次。**後面每一棒的結論都建立在「這支本身是好的」這個前提上**，所以要先確認它沒事。

---

> **現況（2026-10-01）**：本棒各條後來的處理結果如下（過程紀錄保留，不改）。
>
> - 問題 1、2（刪佐證文件／文件庫檢查與刪除對象不同）＝M11 第 11 條，✅ 已修（CM-2041，commit `15d4efd3c`，1.21.0 出貨）。
> - 問題 3（外洩金鑰）：✅ 已修（CM-2048 `e8e134a4e`／CM-2051 `ccbb27148`，殘留字串已清）。
> - 問題 4（資料庫密碼寫死腳本）：✅ 已修（CM-2049，commit `5d4c14221`）。
> - 問題 5（每套安裝同一組最高管理員密碼）：🚫 裁定不修（M17 第 7 條，原廠系統殼帳號、密碼只有原廠知道）。

## 找到什麼：5 個問題

| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🟡 中 | **刪除佐證文件時，只檢查「你管不管網址上那份計畫」，刪的卻是「你給的文件編號」**——兩者沒核對 | 任何管理一個專案的人，可以刪掉**任何其他專案、任何其他公司**的佐證文件連結。被害者會看到文件從控制項底下無聲消失，沒有錯誤訊息，紀錄上也查不到是誰做的 | ① 任何登入帳號<br>② 自己開一個專案（**開的人自動變負責人，不需核准**）<br>③ 知道目標文件編號（**當該專案的唯讀成員就拿得到**） | `app/oscal/service/ssp_control_implementation_service.py:890` |
| 2 | 🟡 中 | **文件庫的刪除有同樣的問題，而且更嚴重**——連帶會刪掉所有關聯，還可能把實體檔案一起銷毀 | 同上，外加：該文件底下所有「掛在哪些控制項／查核項目」的關聯全部連鎖刪除；如果系統判斷沒有別人在用這個檔，**實體檔案也會被永久刪除** | 同上。編號取得更容易——有一支查詢**任何登入者都打得到**，不檢查權限 | `app/oscal/service/ssp_document_pool_service.py:129` |
| 3 | 🟡 中<br>⚠️ 範圍外 | **正式在用的 API 金鑰被寫在一份對話紀錄裡**（OpenAI／Anthropic／Google 金鑰、Google 雲端硬碟的應用程式密鑰、保護客戶雲端權杖的加密金鑰） | 拿得到程式碼的人可以拿公司帳單去用 AI 服務；也能解密系統存著的客戶雲端硬碟權杖 | 讀得到程式碼倉庫。**驗證員實際比對過：其中三把跟現在 `.env` 裡的一模一樣，從來沒換過** | `docs/conversation-history/2026-05-29/.../1035-c5c2e417-5-28.md:2041`（同樣的值散在約 11 個檔） |
| 4 | 🟡 中<br>⚠️ 範圍外 | **資料庫密碼被寫死在一支受版控的文件產生腳本裡** | 讀得到程式碼、又連得到內網的人，可以直接連進資料庫讀寫所有稽核證據與使用者資料。同一組密碼也用在產出貨映像檔的基準資料庫上 | ① 讀得到程式碼倉庫<br>② 連得到內網資料庫主機 | `docs/system-design/scripts/generate_db_schema_docx.py:34` |
| 5 | 🟡 中<br>⚠️ 範圍外 | **每一套安裝出來的系統，最高管理員密碼都是同一組**，而且不強制改 | 那組密碼只要外流一次（交接信、支援工單截圖、某台客戶機器被入侵），**所有已安裝的客戶端同時淪陷**，沒有隔離 | ① 要先知道或破解那組密碼（雜湊在程式碼裡，但明文不在，且用 bcrypt cost 12 很難破）<br>② 連得到任何一套部署的登入頁 | `scripts/init/06-admin.sql:41` |

**一到二號是這棒範圍內的。三到五號是研究員沿著相依關係追出去撿到的**——它們是真的，但那些目錄從來不是這次的掃描目標，所以**不能因為只找到這三條就認為那些地方乾淨**。

---

## 詳細說明

### 問題 1、2：檢查一個對象，動手在另一個對象上

這兩條是同一個錯誤在兩個地方出現，一起說。

**白話**：刪除文件的網址長這樣——

```
DELETE /ssp/<哪一份系統安全計畫>/control-implementation/.../reference-document/<哪一份文件>
```

網址上有**兩個編號**。程式做了權限檢查，但檢查的是前面那個：「你是不是**網址上這份計畫**的負責人？」是的話就放行。接著刪除的，是後面那個編號指到的文件——**而這兩個編號從頭到尾沒有互相核對過**。

所以只要在前面放**自己的**計畫、後面放**別人的**文件編號，警衛就會放行。

**首腦自己開檔驗證**（不是採信報告）：

```python
# app/oscal/service/ssp_control_implementation_service.py:887-890
@transaction
def delete_reference_document(self, ssp_uid: str, doc_uid: str):
    self._perm.require_manager(ssp_uid)          # ← 檢查前面那個編號
    return self._ref_doc_ds.delete_by_uid(doc_uid)  # ← 刪後面那個編號
```

**整個方法只有兩行，中間什麼都沒有。** 連權限檢查的回傳值都沒留著用。這是照著檔案唸出來的事實，不是推論。

文件庫那支（問題 2）稍微複雜一點，但結果一樣：

```python
# app/oscal/service/ssp_document_pool_service.py:118-129
ctx = self._perm.require_manager(ssp_uid)        # 檢查前面那個編號
...
doc = next((d for d in self._pool_query.list_pool(ctx.ssp.id)   # 看起來像在核對
            if d.get("uid") == doc_uid), None)
file_uid = doc.get("file_uid") if doc else None  # ← 但只拿來取檔案編號，找不到就放 None 繼續跑
ref_doc = self._ref_doc_ds.get_by_uid(doc_uid)   # ← 這裡拿到真正那筆，卻沒檢查它屬於誰
...
result = self._ref_doc_ds.delete_by_uid(doc_uid) # 刪後面那個編號
```

第 123 行**看起來**像在確認「這份文件是不是你的」，但它的產出只有檔案編號；找不到的時候就填 `None` 然後繼續往下跑，不會擋。第 125 行明明已經把那筆真正的紀錄抓在手上了，**卻沒有問一句「它屬於哪份計畫」**。整段從頭到尾沒有一個 `if` 擋在刪除前面。

**攻擊怎麼進行**（整個過程不需要任何管理員協助）：

1. 攻擊者是被害公司某個專案的**唯讀成員**——只能看、不能改。
2. 他呼叫列表功能，把該專案所有文件編號抄下來（唯讀成員本來就看得到）。
3. 他自己開一個新專案。**開專案的人系統自動指派為該專案負責人**，這是設計如此、不需誰核准。
4. 他送出刪除請求：前面放**自己**的計畫、後面放**被害者**的文件編號。
5. 警衛檢查前面那個——通過。被害者的文件被刪。
6. 重複，把抄下來的編號刪光。

**為什麼資料庫沒擋下來**：系統其他大部分資料表都有「每家公司只看得到自己資料」的資料庫層防護，但**存這些文件的那張表沒有公司欄位、也沒有設這個防護**。所以上層放行之後，底下沒有第二道關卡。

**怎麼修**：刪之前先把文件撈出來，確認它確實屬於被授權的那份計畫，不是就回「找不到」。**同一個 repo 裡已經有一支寫對了**——`app/module_frame/service/module_frame_reference_document_service.py:147-151` 就是先撈再比對歸屬，照它的寫法抄過來即可。文件庫那支還要注意：檔案編號要從「已驗證過歸屬的那筆」上取，不要再走另一條查詢，不然兩邊哪天又會各走各的。

另外建議替那張表補上公司欄位與資料庫層防護，當作第二道保險——這個 schema 裡每一張有租戶概念的表都有，只有它沒有。

---

### 問題 3：正式在用的金鑰躺在一份對話紀錄裡

**白話**：有人在某次工作階段裡執行了「把設定檔印出來」的指令，而那次的**完整對話逐字稿連同印出來的祕密一起被提交進版控**。裡面有五樣東西：OpenAI 金鑰、Anthropic 金鑰、Google 金鑰、Google 雲端硬碟的應用程式密鑰，以及保護客戶雲端硬碟權杖的加密金鑰。同樣的值散在約 11 個檔案裡。

設定檔本身是有被排除在版控之外的——**這正是這件事嚴重的地方**：逐字稿等於繞過了那道本來有效的保護。

**這條最該立刻處理的部分**：驗證員把逐字稿裡的值拿去跟**現在正在用的設定檔**逐一比對，發現其中三把——Google 金鑰、雲端硬碟應用程式密鑰、雲端權杖加密金鑰——**一個位元都沒變，從來沒換過**。先前一份內部掃描紀錄（FR-079 的 B2 報告第 298 行）也記載 2026-09-08 那次清理只刪了測試用設定檔，這些逐字稿留在原地。

**怎麼修**：五把全部去各自的服務商那邊作廢重發（**不要相信任何「已經換過了」的提交訊息**，上面的比對說沒有）。然後把值從現有檔案裡清掉，並在對話歸檔流程裡加一道遮蔽步驟，讓原始設定檔內容不可能再進 commit。

---

### 問題 4：資料庫密碼寫死在文件產生腳本裡

**白話**：一支產生資料庫結構文件的腳本，**把資料庫密碼直接打在程式碼裡**，然後拿去連線。驗證員確認那不是佔位字串——它跟現在設定檔裡正在用的資料庫密碼（以及 Redis 密碼，同一組）完全一樣。而設定檔有被排除版控，**所以這個程式碼倉庫就是這組密碼唯一的散布途徑**。

同一組密碼也用在產出貨映像檔的基準資料庫上，所以影響不只這一台機器。

**怎麼修**：把所有共用這組密碼的主機上的帳號密碼換掉，腳本改成跟主程式一樣從環境變數讀連線設定，並把這個值從重複提到它的文件裡清掉（研究員數到約 249 個檔）。

---

### 問題 5：每套安裝的最高管理員密碼都一樣

**白話**：安裝程式在建資料庫時，會建一個最高權限的 `admin` 帳號，**而那組密碼的雜湊值是寫死在安裝腳本裡的常數——每一套客戶安裝出來都一模一樣**。而且那支腳本明確選擇不要求首次登入改密碼，維運指令裡的「輪換憑證」也只換資料庫與 Redis 的密碼，不含這個帳號。

「最高權限」在這裡的意思是：**這個帳號繞過「每家公司只看自己資料」的隔離機制**。

**為什麼是中不是高**：明文密碼本身不在程式碼裡，雜湊用的是 bcrypt cost 12，破解代價很高。要打到得先靠別的管道知道那組密碼。但一旦知道，**所有客戶端同時淪陷**，這是它不能更低的理由。

**怎麼修**：每套安裝各自產生一組密碼（安裝程式**現在已經會這樣處理資料庫和 Redis 的密碼了**，照抄即可），在安裝過程印一次給客戶，然後要嘛強制首次登入改密碼、要嘛把這個帳號納入輪換指令。資料庫裡只該存產生出來的雜湊，絕不該是出貨檔案裡的常數。

---

## 那支權限判斷程式本身呢？

**沒有被判定有問題。**

`common/authz/ssp.py` 是這棒特意拉進來的重點——全系統只有這一支在判斷誰能看、誰能改系統安全計畫。兩位研究員和三位驗證員在追問題 1、2 的過程中都讀過它，一致描述它「確實做到了它宣稱要做的事」：解析網址上那份計畫、比對呼叫者在該專案的角色。

**找到的兩個洞不在警衛身上，在呼叫端**——它們問了警衛一個正確的問題（「你管不管這份計畫？」），但接下來動手的對象是另一份文件。

不過**這不等於一張健康證明**。這次用的是快速檔（一位研究員一輪），派工時特別點名的兩個問題並沒有被單獨回答：

- 有沒有哪個「寫入類」操作走的是比較寬鬆的「只要是專案成員」那條路，因而繞過「已定案就不能再改」的限制
- 萬一相依元件沒接好、判斷器是空的，程式會直接炸掉還是無聲放行

這兩題如果會影響後面的決策，**需要另外開一棒針對性地讀**。

---

## 執行概況

| 項目 | 內容 |
|---|---|
| 掃描範圍 | 6 支檔、1,943 行（控制項實作線 ＋ `common/authz/ssp.py`）|
| 程式碼版本 | `77a8906d`（branch `feature/review`，工作區有未提交異動）|
| 工具設定 | `claude-security` plugin v0.11.0／effort `low`／focus `attack-surface` |
| 派出／回報 | 17 個 agent 派出，17 個回報，0 失敗 |
| 候選發現 | 6 條原始候選，去重後 5 條 |
| 面板驗證 | **有跑完**。5 條各由 3 位驗證員從不同角度投票，共 15 票，**全部 3:0 通過** |
| 嚴重度調整 | 問題 3 由研究員的「高」被面板降為「中」（票數 中／高／中），本報告照降後的寫 |
| 驗證章 | `verified` |
| 報告原檔 | `CLAUDE-SECURITY-20260921-061133/`（工具產出，含 JSONL／SARIF／版本戳記）|

⚠️ **沒有任何程式碼被執行過。** 沒有跑測試、沒有實際發動攻擊、沒有驗證過概念驗證程式。所有結論都來自讀程式碼。

---

## 建議的後續

1. **立刻**：問題 3 的三把還活著的金鑰去作廢重發。這是唯一「現在就在外面」的東西。
2. **併入修正卡**：問題 1、2 是同一個修法、同一個既有正確範本可抄，適合一張卡做完。
3. **問題 4、5 交給首腦判斷**——它們超出本棒範圍，可能跟其他棒的發現重複（問題 4 與 FR-081 的 I2 報告第 1 條疑似同源）。
4. **考慮補一棒**：上面「權限判斷程式本身」段落裡那兩個沒被回答的問題。
