檢查日期 2026-09-21|掃描耗時 1 小時 50 分|對應卡片 CM-2003
派工時指定要追的那條線,追到了——但真正的洞不在原本預判的位置。
Excel 匯入有三條去路,其中兩條有權限檢查、第三條完全沒有。走那條沒檢查的路,任何登入者(連只有唯讀權限的稽核員都算)可以把別家公司、甚至原廠公版的合規範本整份清空,換成自己上傳的檔案。而系統裡改同一份資料的正規入口,是有要求權限的。
另外一條中風險:Excel 上傳只擋「壓縮後 10MB」,但讀檔時是整份塞進記憶體——一個精心壓縮的小檔可以讓落地版的容器被系統殺掉,整個產品停擺。
使用者把 Excel 檔傳上來 → 系統存起來並開一張「解析工作單」→ 使用者看預覽 → 按下確認 → 資料才真的寫進系統安全計畫。這一棒掃的就是**「誰能傳、誰能看預覽、誰能按確認」**這條路,共 5 支檔、1,460 行。
不含把 Excel 檔案內容拆開來讀的那七支程式(那批是 O3b)。
現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。
- 問題 1(覆寫既有資源庫無權限檢查)=M11 第 2 條,✅ 已修(CM-2178,commit
abf1cf8a4;前半 CM-204060015232f,1.21.0 出貨)。- 問題 2(Excel 壓縮炸彈)=M11 第 23 條,✅ 已修(CM-2181,commit
48638cbdb/套件2cb9d2cd,1.21.0 出貨)。
| # | 嚴重度 | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 |
|---|---|---|---|---|---|
| 1 | 🔴 高 | 覆寫既有資源庫這條路,從頭到尾沒有權限檢查。上傳時只問「這個編號指到的範本存在嗎」,存在就放行;按確認時直接清空那份範本再寫入 | 任何登入者可以把別家公司或原廠公版的合規範本整份清空重寫——控制項清單、每條控制項的實作說明、相關人員與角色全部連鎖刪除,攻擊者上傳的檔案成為新的合規基準 | ① 任何一個登入帳號(不需要任何範本相關權限) ② 一個範本編號(從列表就拿得到,列表也沒有權限檢查) ③ 一份格式正確的 Excel(範本檔產品裡可下載) |
app/oscal/service/ssp_excel_import_app_service.py:478(確認端):816-817(上傳端) |
| 2 | 🟡 中 | 上傳只擋壓縮後的大小,讀檔時卻整份塞進記憶體 | 一個 10MB 以內、但解壓後膨脹到幾 GB 的 Excel,可以把容器的記憶體吃光。落地版是單一容器,被系統殺掉就是整個產品停擺 | 任何一個登入帳號 + 一個刻意壓縮過的 Excel 檔 | app/oscal/service/excel_parser/parser.py:42(入口在 ssp_excel_import_app_service.py:172,⚠️ 這支檔屬 O3b 範圍) |
白話:Excel 匯入完,資料可以寫到三個地方:
| 去路 | 寫到哪 | 有檢查嗎 |
|---|---|---|
寫進某個專案的計畫(ssp) |
專案的系統安全計畫 | ✅ 有,要求是該專案的負責人 |
新建一個資源庫(framework_version) |
新的範本 | ✅ 有,驗框架版本存在 |
覆寫既有資源庫(module_frame) |
既有範本,先清空再寫 | ❌ 完全沒有 |
第三條路是這樣寫的:
# 上傳端 app/oscal/service/ssp_excel_import_app_service.py:803-817
def _verify_source_exists(self, source_type, source_uid):
if source_type == "framework_version":
...驗框架版本存在...
elif source_type == "ssp":
self._require_project_manager(source_uid) # ← 這條有守門
...
else: # module_frame
self._resource_library.get_resource_library(source_uid) # ← 只驗「存在」get_resource_library 只在那筆資料不存在時才會擋,存在就回傳資料、放行。它問的是「有沒有這個東西」,不是「你能不能動這個東西」。
確認端更直接:
# app/oscal/service/ssp_excel_import_app_service.py:475-481
rl = self._resource_library.get_resource_library(job.source_uid)
template_ssp_id = (rl or {}).get("ssp_template_id")
...
self._oscal_io.clear_ssp_body(template_ssp_id) # ← 第 478 行,直接清空
counts, warnings = self._fill_template_ssp(...) # ← 換成攻擊者的檔案中間沒有任何一行在問「你有權限動這份範本嗎」。
clear_ssp_body 不是清一張表,是連鎖刪除(實作在 jedi-oscal-v2 套件的 oscal_io_service.py:299-318):
也就是「這家公司的每條控制項怎麼做到」的完整記錄,一次消失。
# api/module_frame/routes/module_frame_route.py:71-72, 83-84
@require_capability("module-frame.update") # 改範本要這個權限
def put(self, uid, ...):
@require_capability("module-frame.delete") # 刪範本要這個權限
def delete(self, uid, ...):同一份資料,走正門要權限,走 Excel 匯入不用。
POST /api/1.0/oscal/resource-libraries/list 撈範本編號。這個端點也只驗登入(api/oscal/routes/resource_library_route.py:26)。POST /api/1.0/ssp-excel-imports/parse,帶 source_type=module_frame + source_uid=<別人的範本編號>。上傳端只驗存在 → 通過,回傳一個解析編號。POST /api/1.0/ssp-excel-import/<解析編號>/confirm,內容送 {}。確認端第 320 行有一道公司歸屬檢查:
if job.tenant_id != user_context.tenant_id and not user_context.is_admin:
raise NotFound(...)看起來像在擋跨公司操作,但它比的是「這張解析單是不是我建的」——解析單是攻擊者自己建的,公司當然是自己的,永遠通過。真正要比的是「那份範本是不是我公司的」,而那個從來沒比過。
這是和 O1 棒同一類的病:呼叫了檢查、但檢查的對象不是要動的那個東西。
範本這張表(compliance.module_frames)的資料庫層讀取規則,刻意讓原廠公版對每家公司都可見、母公司分享的範本對子公司可見:
-- scripts/init/02-schema.sql:24323
CREATE POLICY module_frames_select ... USING (
scope = 'SYSTEM' -- 原廠公版:所有公司都看得到
OR ...app_tenant_allowed_for_session(tenant_id)
OR (scope = 'SHARED' AND ...is_ancestor_of_session(tenant_id)) -- 母公司分享的
);而寫入規則(module_frames_update / module_frames_delete)明確擋掉了改原廠公版——但這次的破壞根本沒動到這張表,動的是 oscal.* 那批放計畫內容的表。實際數過:
$ grep "ALTER TABLE oscal\..* ENABLE ROW LEVEL SECURITY" scripts/init/02-schema.sql
ALTER TABLE oscal.ap_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.ar_xlsx_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.framework_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.ssp_docx_parse_jobs ENABLE ROW LEVEL SECURITY;
ALTER TABLE oscal.ssp_excel_parse_jobs ENABLE ROW LEVEL SECURITY;只有五張「解析工作單」表有資料庫層防護,被清掉的那批計畫內容表一張都沒有。 程式這一層是唯一的關卡,而它沒關。
(這與 O2 問題 1 的結構相同:警衛程式存在、資料庫沒有第二道、而這條路徑沒叫警衛。)
隔壁的 Word 匯入已經解過同一題,照抄就好:
# app/oscal/service/ssp_docx_import_app_service.py:161-162
if not viewer_has_capability("module-frame.create"):
raise ForbiddenError(FlowControlErrorCode.GRC_DOCX_NO_PERMISSION_FOR_SOURCE)那支檔的註解還寫明了為什麼用 viewer_has_capability 而不是網址入口的裝飾器:這個判斷夾在分支中間,要先知道是哪條去路才知道要不要判,入口那層表達不了。
具體兩件事:
_verify_source_exists 的 module_frame 分支與 _confirm_update_module_frame 兩處,都要求 module-frame.update。SYSTEM)直接拒絕。判法照抄 common/authz/sharing.py 既有的。兩處都要補,確認端是關鍵那一道——只補上傳端的話,攻擊者用另一個 source_type 建的解析單一樣能繞過(confirm_import 會照 job 裡存的 source_type 重新分派)。
白話:Excel 檔(.xlsx)其實是一個壓縮檔。系統只檢查壓縮後的大小不超過 10MB,然後用「整份讀進記憶體」的方式打開它。
一個壓縮後 10MB 的檔,解壓後可以膨脹到幾 GB——只要裡面宣告「我有幾億個格子」就行。
# app/oscal/service/ssp_excel_import_app_service.py:124-126(上傳端唯一的大小檢查)
file_size = self._get_file_size(file)
if file_size > _EXCEL_MAX_SIZE: # _EXCEL_MAX_SIZE = 10MB,壓縮後的大小
raise BadRequestError(...)
# app/oscal/service/excel_parser/parser.py:42(讀檔端)
wb = load_workbook(file_path, data_only=True, read_only=False)
# ↑ 非串流模式:整份建成記憶體物件為什麼落地版特別嚴重:config/config.py 對另一個設定的註解自己就寫了——「落地版單一容器,撐爆記憶體會讓容器被系統殺掉、整個產品停擺」。那個設定擋的是 HTTP 請求的大小,擋不到解壓後的工作簿。而解析是在請求裡同步做的,打幾次就夠。
每張工作表 5000 列的上限救不了——那個檢查在整份已經進記憶體之後才跑。
兩件事,都不大:
zipfile.ZipFile(path).infolist() 把解壓後的總大小加起來,超過上限(例如 200MB 或膨脹倍率 100 倍)就拒絕。load_workbook(..., read_only=True)。既有的 _iter_data_rows 本來就是一列一列走,改了不影響邏輯。Word 匯入那條線同樣只擋壓縮後大小,要一起補。
派工卡要求高風險每條自己開檔核對。上面每一行引用都開檔讀過了,另外修正了掃描工具兩處說法:
派工卡問「解析編號(parse_uid)是不是流水號,可不可以猜」。不是流水號:
# domain/oscal/service/ssp_excel_parse_job_domain_service.py:29
entity = SspExcelParseJobEntity(
uid=str(uuid.uuid4()), # ← 隨機碼,不是流水號所以「偷別人的解析編號」不是可行的攻擊路徑。問題 1 的攻擊者不需要偷——他自己開一張解析單,指向別人的範本就好。
派工卡點名要查這條。確認不對稱存在:
# api/oscal/__init__.py:110-111
# api.add_resource(SspScopedExcelImportRoute, '/ssp/<ssp_uid>/excel-import/<parse_uid>')
# api.add_resource(SspScopedExcelImportConfirmRoute, '/ssp/<ssp_uid>/excel-import/<parse_uid>/confirm')上傳有綁專案的入口,預覽與確認只有不綁專案的那條。
但這個不對稱是被處理過的——ssp 那條去路的權限檢查刻意下移到商業邏輯層,正是為了涵蓋不綁專案的路徑。檔案裡的註解寫得很清楚:
# app/oscal/service/ssp_excel_import_app_service.py:769-776(_require_project_manager 的說明)
"""P3 security review(2026-06-16):把 manager gate 從 scoped route 下移到
app service 層,讓通用 ungated route(`/ssp-excel-imports/parse` +
`/ssp-excel-import/<uid>/confirm`,僅 `@jwt_required()`)也受保護。"""實際確認三處都有:上傳 :812、預覽 :257(要求參與者)、確認 :520(要求負責人)、廢棄 :298。
所以那條網址不是缺口,缺口是同一個分派點上的另一條分支(module_frame)沒有跟著補。 2026-06-16 那次安全檢討把 ssp 那條補齊了,module_frame 沒人管。
派工卡要求照 O1 的角度整棒檢查。逐支公開方法核對的結果:
| 方法 | 有沒有守門 | 守什麼 |
|---|---|---|
upload_and_parse |
✅ 部分 | ssp 要負責人;framework_version 驗存在;module_frame 只驗存在 ← 問題 1 |
get_parse_result |
✅ | 公司比對 + ssp 要參與者 |
discard_parse |
✅ | 公司比對 + ssp 要負責人 |
confirm_import |
⚠️ | 有一道公司比對,但比的是解析單不是目標範本;ssp 分支再要求負責人,module_frame 分支沒有 ← 問題 1 |
O1 那兩條是「檢查了 A、動手動 B」。這一棒不是那個形狀——module_frame 那條是從頭到尾沒檢查。倒是 confirm_import:320 那道公司比對屬於相鄰的陷阱:看起來在擋跨公司,實際擋不到目標資源,容易讓後續讀程式的人以為這裡有守。
_safe_unlink 清暫存檔(:175),路徑用 tempfile.NamedTemporaryFile 產生,不吃使用者給的檔名,所以沒有「檔名帶 ../ 寫到別的目錄」的問題。使用者的檔名只用在副檔名判斷與顯示。confirm_import:325 與 get_parse_result:261 都有過期檢查,過期後不能確認。| 項目 | 實際情形 |
|---|---|
| 範圍 | 5 支檔、1,460 行(Excel 上傳入口與確認流程) |
| 修訂版本 | bac27ddd(branch feature/review,工作區有未提交改動) |
| 工具設定 | effort=low、focus=attack-surface |
| 派出/回報 | 研究員 2 派 2 回,零重試(沒撞到那個會誤殺研究員的上游 bug) |
| 候選 | 3 條,去重後 2 條 |
| 審查面板 | ✅ 完整:6 票全投(3 位審查員 × 2 條)、零漏投、兩條都 3:0 |
| 驗證章 | verification.status: verified |
| 耗時 | 1 小時 50 分 |
| 產物 | CLAUDE-SECURITY-20260921-081023/(未入版控,該目錄自帶 .gitignore) |
說明:這一輪是快速檔(low)的單一研究員掃描 + 完整面板驗證。範圍外的 excel_parser/parser.py 是順資料流追出去撞到的(問題 2 的位置),那支檔屬 O3b 範圍,這裡只報,不代表 O3b 掃過了。
掃描過程沒有執行任何程式碼——沒跑測試、沒發實際攻擊、沒驗證過概念驗證程式。每一條發現與每一處核對,都來自讀這個修訂版本的原始碼。