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

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

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


§1

🔴 一句話結論

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

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


§2

這一棒在檢查什麼

「控制項實作」是整個系統最核心的資料:每一條合規要求底下,「我們公司實際上怎麼做到這條」的文字記錄與佐證文件。這一棒檢查 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 條,原廠系統殼帳號、密碼只有原廠知道)。
§3

找到什麼:5 個問題

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

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


§4

詳細說明

問題 1、2:檢查一個對象,動手在另一個對象上

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

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

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

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

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

首腦自己開檔驗證(不是採信報告):

# 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)稍微複雜一點,但結果一樣:

# 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 的密碼了,照抄即可),在安裝過程印一次給客戶,然後要嘛強制首次登入改密碼、要嘛把這個帳號納入輪換指令。資料庫裡只該存產生出來的雜湊,絕不該是出貨檔案裡的常數。


§5

那支權限判斷程式本身呢?

沒有被判定有問題。

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

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

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

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

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


§6

執行概況

項目 內容
掃描範圍 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/版本戳記)

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


§7

建議的後續

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