O3a 檢查結果:Excel 上傳入口與確認流程

O3a 檢查結果:Excel 上傳入口與確認流程

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


§1

🔴 一句話結論

派工時指定要追的那條線,追到了——但真正的洞不在原本預判的位置。

Excel 匯入有三條去路,其中兩條有權限檢查、第三條完全沒有。走那條沒檢查的路,任何登入者(連只有唯讀權限的稽核員都算)可以把別家公司、甚至原廠公版的合規範本整份清空,換成自己上傳的檔案。而系統裡改同一份資料的正規入口,是有要求權限的。

另外一條中風險:Excel 上傳只擋「壓縮後 10MB」,但讀檔時是整份塞進記憶體——一個精心壓縮的小檔可以讓落地版的容器被系統殺掉,整個產品停擺。


§2

這一棒在檢查什麼

使用者把 Excel 檔傳上來 → 系統存起來並開一張「解析工作單」→ 使用者看預覽 → 按下確認 → 資料才真的寫進系統安全計畫。這一棒掃的就是**「誰能傳、誰能看預覽、誰能按確認」**這條路,共 5 支檔、1,460 行。

不含把 Excel 檔案內容拆開來讀的那七支程式(那批是 O3b)。


現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • 問題 1(覆寫既有資源庫無權限檢查)=M11 第 2 條,✅ 已修(CM-2178,commit abf1cf8a4;前半 CM-2040 60015232f,1.21.0 出貨)。
  • 問題 2(Excel 壓縮炸彈)=M11 第 23 條,✅ 已修(CM-2181,commit 48638cbdb/套件 2cb9d2cd,1.21.0 出貨)。
§3

找到什麼:2 個問題

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡
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 範圍)

§4

詳細說明

問題 1:覆寫既有資源庫這條路沒有權限檢查(高)

白話: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 匯入不用。

攻擊怎麼進行

  1. 攻擊者用任何一個登入帳號——唯讀稽核員就夠,不需要任何範本相關權限。
  2. 打 POST /api/1.0/oscal/resource-libraries/list 撈範本編號。這個端點也只驗登入(api/oscal/routes/resource_library_route.py:26)。
  3. 從產品裡下載 Excel 範本檔,填一列垃圾資料。
  4. POST /api/1.0/ssp-excel-imports/parse,帶 source_type=module_frame + source_uid=<別人的範本編號>。上傳端只驗存在 → 通過,回傳一個解析編號。
  5. POST /api/1.0/ssp-excel-import/<解析編號>/confirm,內容送 {}。
  6. 第 478 行清空那份範本,換成攻擊者的檔案。

⚠️ 這裡有一道「看起來有守、實際守不到」的檢查

確認端第 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 而不是網址入口的裝飾器:這個判斷夾在分支中間,要先知道是哪條去路才知道要不要判,入口那層表達不了。

具體兩件事:

  1. 要權限:_verify_source_exists 的 module_frame 分支與 _confirm_update_module_frame 兩處,都要求 module-frame.update。
  2. 要驗歸屬:把撈出來的那份範本的公司,跟呼叫者的公司比。不同就拒絕;是原廠公版(SYSTEM)直接拒絕。判法照抄 common/authz/sharing.py 既有的。

兩處都要補,確認端是關鍵那一道——只補上傳端的話,攻擊者用另一個 source_type 建的解析單一樣能繞過(confirm_import 會照 job 裡存的 source_type 重新分派)。


問題 2:Excel 壓縮炸彈(中)

白話: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 列的上限救不了——那個檢查在整份已經進記憶體之後才跑。

怎麼修

兩件事,都不大:

  1. 解析前先當壓縮檔檢查:用 Python 內建的 zipfile.ZipFile(path).infolist() 把解壓後的總大小加起來,超過上限(例如 200MB 或膨脹倍率 100 倍)就拒絕。
  2. 改成串流讀取:load_workbook(..., read_only=True)。既有的 _iter_data_rows 本來就是一列一列走,改了不影響邏輯。

Word 匯入那條線同樣只擋壓縮後大小,要一起補。


§5

🔵 我親手核對的部分(含兩處修正掃描工具的說法)

派工卡要求高風險每條自己開檔核對。上面每一行引用都開檔讀過了,另外修正了掃描工具兩處說法:

修正一:解析編號猜不到

派工卡問「解析編號(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 那種「呼叫了檢查卻丟掉結果」

派工卡要求照 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 都有過期檢查,過期後不能確認。
  • 上傳檔案大小上限:有擋,但擋的層次不對 → 就是問題 2。

§6

掃描執行概況

項目 實際情形
範圍 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 掃過了。

掃描過程沒有執行任何程式碼——沒跑測試、沒發實際攻擊、沒驗證過概念驗證程式。每一條發現與每一處核對,都來自讀這個修訂版本的原始碼。