O9a 檢查結果:Excel/Word 兩線共用的資料轉換接縫

O9a 檢查結果:Excel/Word 兩線共用的資料轉換接縫

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


§1

🔴 一句話結論

掃完了,找到 3 個問題,最嚴重的是兩條:任何一個登入帳號(哪怕是只能看的旁觀者)都能把整個公司的合規範本清空並換成自己的內容——Excel 一條、Word 一條,兩條各自獨立成立。

那份範本是所有新專案的起點,被換掉之後每一個之後開的稽核專案都會長在被污染的內容上,而且清空是連鎖刪除(控制措施、對應條文、系統元件、負責單位、外部授權、設備資產全消失),沒有復原按鈕。

派工時點名要追的第一問——「兩條線呼叫共用函式的方式一不一樣」——答案是「有,而且是重點」:詳見下方〈兩線並排比對〉。兩線在權限這一層的不一致最嚴重(Word 那條的確認步驟還額外接受使用者自己指定要覆寫誰),在資料防護這一層則是兩邊都沒驗、一起漏。

派工時點名要追的第三問(「有沒有把檔案裡的編號直接當系統編號用」)有一條,就是下方問題 3——不是覆寫既有資料的那種形狀,而是「偽造某個人被對應到這筆資料上」。

O3b 交代要追的公式注入殘段(= + - @ 開頭的儲存格):本棒範圍內沒有中和,但也沒有惡化——這幾支接縫檔只做搬運,不動內容,該補的位置仍在 O3b 指出的解析端與匯出端,本棒不另計。


§2

這一棒在檢查什麼

使用者上傳一份系統安全計畫(Excel 或 Word),系統要把裡面的文字轉成內部資料格式存起來。這一棒掃的是「轉換」這個動作,也就是 Excel 與 Word 兩條匯入線唯一共用的那支程式(_common.py,620 行、20 支函式),外加兩支呼叫它的檔(各約 150 行)。共 3 檔、916 行。

之所以要單獨掃一棒,是因為共用檔有一種只有並排看才看得出來的病:同一支函式,Excel 那邊呼叫前先驗過、Word 那邊直接送進來(或反過來),分開在兩棒裡看永遠看不到。


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

  • 問題 1、2(Excel/Word 匯入無權限檢查,Word 確認可換目標)=M11 第 2、3 條,✅ 已修(CM-2178,commit abf1cf8a4,1.21.0 出貨)。
  • 問題 3(負責人對應使用者編號由使用者自填):M11/M12 頁與 SUMMARY 都查不到對應條目,保留原文、現況待核。
§3

找到什麼:3 個問題

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡
1 🔴 高 Excel 匯入沒有檢查這個人有沒有權限改範本,只檢查了有沒有登入 任何登入帳號可以把公司的合規範本整份清空再換成自己的內容。同一份範本會被複製到每個新開的專案,等於污染所有未來的稽核。若目標是系統公版範本(設計上人人可讀),破壞範圍跨出自己公司 ① 任何一個登入帳號(不需任何角色或專案身分)
② 範本的識別碼——由「列出資源庫」這支同樣只驗登入的端點提供給每個登入者
③ 一份格式合法的 Excel(內容可以是垃圾)
app/oscal/service/ssp_excel_import_app_service.py:478(_confirm_update_module_frame)
入口 api/oscal/routes/ssp/ssp_excel_import_route.py:34(上傳)與 :101(確認)
2 🔴 高 Word 匯入同樣沒有檢查權限,而且確認的那一步還接受使用者自己指定「要覆寫哪一份範本」 同上,但更直接:攻擊者可以先用一份自己有權限碰的範本建立工作,等到按確認時再把目標換成別人的範本 ① 任何一個登入帳號
② 一份能被解析的 Word 檔(用來建立待確認的工作)
③ 目標範本的識別碼(同上,人人拿得到)
app/oscal/service/ssp_docx_import_app_service.py:384(confirm_import)
接受外部指定目標在 :335
入口 api/oscal/routes/ssp/ssp_docx_import_route.py:94
3 🟡 中 「這位負責人對應到系統裡哪個使用者」這個編號,使用者可以自己填,系統不檢查型態就存下去 兩種後果:①填一個不是數字的值,之後任何人打開這份範本的匯入預覽都會直接壞掉(回 500),而且會一直壞到有人去資料庫改掉;②填別人的編號,就在資料上偽造出「某某人是這筆資料的負責人」,開新專案時系統會照這個假連結自動建議把那個人拉進專案 ① 能按「確認匯入」(依問題 1,就是任何登入帳號)
② 那份檔案裡至少要有一列負責單位或參與人員
③ 壞掉那半邊還需要有人之後去打開該範本的匯入預覽
存進去:app/oscal/service/import_adapter/_common.py:123(match_props)
使用者填得到:app/oscal/service/ssp_excel_import_app_service.py:695
讀爆掉:app/oscal/service/import_diff/snapshot_views.py:90

§4

🔴 兩線並排比對(本棒的核心題目)

派工卡指定的第一問:同一支共用函式,Excel 與 Word 兩邊呼叫前驗的東西一不一樣。逐支比對結果如下。

一、權限這一層:兩邊都沒守,但破法不同(問題 1、2)

Excel 線 Word 線
上傳步驟有沒有檢查權限 ❌ 只驗登入 ❌ 只驗登入
確認步驟有沒有檢查權限 ❌ 只驗登入 ❌ 只驗登入
檔案裡唯一一處權限檢查 無 有一處(ssp_docx_import_app_service.py:161 檢查「能不能建範本」),但只守在「另開新範本」那條岔路,覆寫既有範本這條走不到它
確認時能不能換目標 不能(用工作建立時記下的目標) ⚠️ 能(:335 讀使用者送來的 source_uid)

這正是前九棒反覆出現的同一個病:有人守了一半。 Word 那條檔案裡明明有一支能力檢查,卻只擋在「建立新的」那條岔路上;「清空既有的再覆寫」這條——破壞力更大的那條——旁邊什麼都沒有。Excel 那條更乾脆,整支檔案一處檢查都沒有,而且 ssp_excel_import_app_service.py:128 還留著一行註解寫「權限由 RBAC middleware 處理」——這句話是假的,實際掛在這條路上的只有登入、記錄、追蹤編號、唯讀授權四樣,沒有一樣看能力。

對照組:同一份資源庫的其他寫入端點(api/module_frame/routes/ 底下)每一支都掛著 @require_capability("module-frame.update")。也就是說同一筆資料,正門有鎖,這兩條匯入側門沒有。

二、資料防護這一層:兩邊一起漏(問題 3)

共用函式 match_props(_common.py:89)負責把「這位負責人對應到系統裡的誰」寫進資料。它假設送進來的編號是系統自己比對出來的,所以拿到什麼就 str() 存什麼。

  • Excel 線:呼叫前沒有驗(excel_to_oscal_ssp.py:43、55)。而且 Excel 這條的確認步驟允許使用者用「內容覆寫」功能塞任意欄位到任意一列(ssp_excel_import_app_service.py:695 的 rows[idx][k] = v,只檢查列號、不檢查欄位名),所以這個欄位實際上是使用者可以直接寫的。
  • Word 線:呼叫前也沒有驗(docx_to_oscal_ssp.py:48)。

兩邊一致地沒驗——這是「共用函式假設呼叫者已驗、兩個呼叫者都假設共用函式會驗」的典型形狀。諷刺的是同一個目錄下就有現成的正確寫法:party_match_enrich.py:22 的 _to_int() 正是「轉得成整數才用、轉不成就丟掉」,只是沒被這條路用上。

三、其餘共用函式:兩邊呼法一致,沒找到不對稱缺口

以下逐支確認過兩線的呼叫方式,沒有發現「一邊驗過、另一邊沒驗」:

共用函式 比對結果
clean / gen_uuid 兩線用法相同,純字串整理與產生識別碼,不吃使用者語意
dedupe_parties / dedupe_rows 兩線都是最後統一呼叫一次;去重的判斷欄位(型態+名稱,去空白、不分大小寫)對兩線一致。判太鬆會合併不同人的疑慮不成立——鍵包含型態與完整名稱,只有完全同名同型態才合併,且合併行為本身是刻意的(修過的重複匯入問題)
build_system_characteristics 兩線參數相同。差別只在 Word 線通常不注入「查使用者編號」的函式,缺了就跳過寫入,不是缺口
build_components / build_leveraged_authorizations / build_inventory_items 兩線呼叫順序與參數完全一致(先建外部授權拿對照表 → 建元件 → 建資產)
component_uuid_by_title / _resolve_implemented_components 兩線相同。這支雖然是「拿檔案裡的標題去對應系統內的編號」,但對應範圍只限於同一次匯入自己剛產生的元件(comp_uuid_by_title 來自本次 build_components 的產出,不是查資料庫),查不到就略過,不構成「使用者指定要改到誰」
assemble / maybe_by_component / soa_props / impl_status_desc_props 純組裝,兩線一致
_login_name_from_user_label / _resolve_owner_uid 只有 Excel 線實際會用到(Word 側不注入查詢函式)。查不到回 None 不寫入,查詢出錯有攔截不中斷匯入;沒有跨公司撈到別人使用者的路——查詢函式由上層注入,本身在資料庫隔離機制之下

一個小的不對稱(不構成安全問題,但值得記一筆):Word 線的 _build_parties 逐列先檢查「這列是不是字典」(docx_to_oscal_ssp.py:36),Excel 線沒有(excel_to_oscal_ssp.py:33、48)。Excel 的資料來自解析器產出的固定結構,正常情況不會不是字典,所以現在打不出問題;但同一支共用函式的兩個呼叫者對「輸入長什麼樣」抱持不同假設,正是日後長出缺口的地方。修問題 3 時順手補齊即可。


§5

詳細說明

問題 1:任何人都能清空並替換公司的合規範本(高,Excel 線)

白話:系統裡有一份「合規範本」,是公司所有稽核專案的起點——開新專案時會把它整份複製一份過去。這份範本可以用 Excel 匯入的方式更新,而更新它的時候,系統只確認了「你有登入」,沒有確認「你有沒有權限改它」。

打法很直接:

  1. 攻擊者用任何帳號登入(連旁觀者身分都可以),呼叫「列出資源庫」拿到範本的識別碼——這支端點同樣只驗登入。
  2. 上傳一份格式合法但內容是垃圾的 Excel,指定目標是那份範本。
  3. 按確認。系統執行 clear_ssp_body()——連鎖刪除整份範本的內容(控制措施、每條對應條文、系統元件、負責單位、外部授權、設備資產、系統特性全部消失),接著把垃圾寫進去。
# app/oscal/service/ssp_excel_import_app_service.py:478
self._oscal_io.clear_ssp_body(template_ssp_id)

為什麼是高不是中:前提只有「有一個帳號」,沒有任何附加條件;後果是不可逆的資料破壞,而且會沿著「範本 → 新專案」擴散到所有未來的稽核。

跨公司的那一半要說清楚:資料庫對「資源庫」這張表有開客戶隔離,但隔離規則明文允許每個客戶讀得到標記為「系統公版」的那些;而存放範本實際內容的那些表沒有開隔離。所以如果系統公版範本存在,這個攻擊打到的就是全體客戶共用的內容。我沒有連線上資料庫確認目前有沒有這種公版資料,這一半是照設計推的,需要決策者查一次 compliance.module_frames 有無 scope='SYSTEM' 的資料才能定案。

怎麼修:在 Excel 匯入的上傳與確認兩支端點,對目標是資源庫的情況掛上與其他寫入端點相同的能力檢查——common.authz 的 require_capability("module-frame.update")。另外要比照 assert_scope_writable 的做法,確認這份資源庫是呼叫者能寫的(系統公版/共享的,只有平台管理員能動)。順手把 :128 那行假的「權限由 RBAC middleware 處理」註解刪掉——留著會讓下一個人以為已經守過了。

問題 2:Word 匯入同樣沒守,而且確認時還能換目標(高,Word 線)

白話:與問題 1 同一件事,發生在 Word 匯入這條線上。破壞後果完全一樣。但這條多一個毛病:按「確認」的時候,系統會讀使用者送來的「要寫到哪一份範本」,而不是用當初上傳時記下來的那份。

打法比 Excel 那條還省事:

  1. 攻擊者上傳一份隨便的 Word 檔,不指定目標(走「之後再選」那種形狀),拿到一個待確認的工作。
  2. 按確認時,在請求內容裡填上受害範本的識別碼。
  3. 系統解析出那份受害範本,第一次嘗試寫入時因為「已經有內容了」而失敗,接著在錯誤處理裡執行 clear_ssp_body() 清空它,然後把攻擊者的內容寫進去。
# app/oscal/service/ssp_docx_import_app_service.py:384
self._oscal_io.clear_ssp_body(template_ssp_id)

這條線的檔案裡其實有一支能力檢查(:161,檢查「能不能建範本」),但它守在「另開一份新範本」那條岔路上,覆寫既有範本這條根本走不到它。這就是前九棒一再出現的「守了一半」。

怎麼修:兩件事一起做。① 上傳與確認兩支端點掛 require_capability("module-frame.update"),既有的「能不能建範本」檢查保留。② 確認時不要相信請求裡送來的目標,改成拿當初建立工作時記下的那份——一個「確認」動作不應該能改去動一份當初沒被授權的資料。

問題 3:負責人對應到誰,使用者自己說了算(中)

白話:範本裡的每位負責單位/參與人員,系統會試著對應到系統內的某個使用者帳號,並把那個人的編號記在資料上。這個編號本來應該是系統自己比對出來的,但實際上使用者可以在按確認時自己塞進去,而系統存下去之前不檢查它是不是一個合法的編號。

程式是這樣:

# app/oscal/service/import_adapter/_common.py:120-123
muid = parsed_party.get("matched_user_id")
if muid is not None:
    pairs.append(("matched-user-id", str(muid)))

str() 的意思是「拿到什麼都轉成文字存起來」——包括 "not-an-int" 這種東西。使用者塞得進去的原因在確認那一步:

# app/oscal/service/ssp_excel_import_app_service.py:694-695
for k, v in field_overrides.items():
    rows[idx][k] = v      # 欄位名不設限,任何欄位都能覆寫

兩種後果:

  • 把系統打壞:讀回來的地方寫的是 int(raw_uid)(snapshot_views.py:90),沒有防呆。存了一個不是數字的值之後,任何人打開這份範本的匯入預覽都會直接 500,而且會一直壞到有人進資料庫把那個值改掉。
  • 偽造負責人:填一個別人的編號,資料上就出現「某某人是這筆資料的負責人」這個假連結。前端讀回來會顯示那個人的暱稱,開新專案的精靈還會照這個假連結自動建議把那個人加進專案。

為什麼是中不是高:打壞那半邊要等有人去開預覽才發作;偽造那半邊是誤導而不是直接取得權限(被建議加入專案的人仍然要有人按下確認)。但它的前提依賴問題 1——問題 1 修好之後,能打這條的人就縮小到本來就有權限改範本的人,嚴重度會再降一級。

怎麼修(三件,建議一起):

  1. 在 _common.py:123 存進去之前先驗型態——照抄同目錄 party_match_enrich.py:22 的 _to_int():轉得成正整數才存,轉不成就不寫這個欄位。
  2. 把 snapshot_views.py:90 的 int(raw_uid) 改成轉不成就當 None,讓已經被寫壞的資料不再讓整頁掛掉。
  3. 「內容覆寫」不應該開放任意欄位——把 matched_user_id、matched_org_unit_id、match_method 這類系統內部用的欄位從可覆寫清單中排除,不要讓 rows[idx][k] = v 什麼都收。

§6

這份結果可信到什麼程度

分兩層講,因為這兩件事的可信度差很多。

第一層:「這三條是真的嗎」——可信度高。 三條都經過三個獨立檢查員各自投票,9 票全數成立、0 票反對(每條 3 票,三個檢查員分別從「打不打得到」「出事多嚴重」「有沒有其他防護擋住」三個角度看)。我另外自己開檔核對過每一條的關鍵行:兩支確認端點確實只掛 @jwt_required()、clear_ssp_body() 確實在那兩行、str(muid) 與 int(raw_uid) 確實沒有防呆、rows[idx][k] = v 確實不限欄位名。這三條不是推測。

唯一有保留的是問題 1 的跨公司那一半——我沒有連資料庫確認目前有沒有「系統公版」範本存在。同公司內的破壞不受這個保留影響,照樣成立。

第二層:「只有這三條嗎」——中等,不能當成保證。 這一棒是低強度設定,只派一位研究員把 916 行看過一遍,再交給三人面板驗。低強度的設計目的是快速篩出明顯的東西,不是窮盡。此外掃描工具本身有已知的三個盲點(見 security-scan-lead skill 第三節),需要在驗收時自行補。

不過本棒題目很集中(就是兩線並排比對),而且我在面板之外自己逐支對照了 20 支共用函式的兩線呼叫方式(結果見上方〈兩線並排比對〉第三段表格),那一題的覆蓋是完整的。


§7

執行概況

項目 數值
掃描範圍 3 檔/916 行(_common.py 620、excel_to_oscal_ssp.py 144、docx_to_oscal_ssp.py 152)
強度設定 low(一位研究員 + 三人面板)
耗時 5,815 秒(約 1 小時 37 分)
派出的檢查代理 10 個,全部正常回來,0 個失敗、0 個被砍
候選發現 3 條(去重後仍 3 條)
面板票數 9 票全數投出(3 條 × 3 個檢查員各一票),全部投「成立」,沒有漏投、沒有棄權
嚴重度有沒有被面板調降 無
掃描版本 00c60d1deae7309cc65012a288fec19c4f0bd6b8
驗證章 verified

掃描範圍的三個檔全部讀完,沒有略過的區塊(coverage 內 skippedComponents、droppedComponents、lostCandidates 皆為空)。


§8

給首腦的收口建議

  • 問題 1 與問題 2 是同一個病的兩條腿,建議併成一張修正卡——修法相同(掛 require_capability("module-frame.update") + 確認時不信任外部指定的目標),分開修容易只補一邊。
  • 問題 3 建議排在問題 1/2 之後,因為它的可打性依賴問題 1;但第 2 小點(讀回時的防呆)可以獨立先做,成本一行。
  • 待決策者查一件事:compliance.module_frames 有沒有 scope='SYSTEM' 的資料。有的話問題 1/2 的破壞範圍跨出單一客戶,優先序要再往上提。
  • 本棒與 O3b 指出的公式注入殘段無交集,不要合併登記。