O4 檢查結果:Word 上傳與解析全鏈

O4 檢查結果:Word 上傳與解析全鏈

檢查日期 2026-09-21|耗時 2 小時 0 分|對應卡片 CM-2004|只掃不修


§1

🔴 一句話結論

派工時預判的「Word 很可能照抄 Excel 的毛病」成立,而且比 Excel 那條更寬。 Word 匯入有三條去路,兩條有權限檢查、第三條(蓋掉既有合規範本)完全沒有——任何登入者,連唯讀稽核員都算,拿到別人的範本編號就能把整份範本清空重寫,而且動手前還能先把那份範本的內容讀出來。另外還有一條 Excel 那邊已經抓到、Word 這邊同樣中招的壓縮炸彈,以及一條全新的:匯出的 Excel 會把使用者寫的字當成公式執行。


§2

這一棒在檢查什麼

「Word 匯入」是使用者上傳一份 .docx,系統把裡面寫的控制項實作內容解析出來,變成系統安全計畫(SSP)資料的整條路。它有三個去向:

  1. 專案的系統安全計畫——寫進某個稽核專案自己的資料
  2. 建一個新的合規範本(系統裡叫「資源庫」,是原廠或各公司預先寫好的控制項範本)
  3. 蓋掉一個既有的合規範本

另外還有一條單獨的路:「只匯入控制項實作」,走 Excel 檔,有自己的上傳與匯出入口。

這一棒檢查 9 支檔、1,950 行——誰能用這些功能、上傳的東西怎麼被處理、匯出的東西安不安全。純粹的 Word 內容抽取器(domain/oscal/parser/ 那 7 支)不在本棒,在 O7a。兩條匯入線共用的接縫檔 _common.py 也不在本棒,在 O9。


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

  • 問題 1(Word 匯入蓋掉既有範本無權限檢查)=M11 第 3 條,✅ 已修(CM-2178,commit abf1cf8a4,1.21.0 出貨)。
  • 問題 2(匯出 Excel 公式)=M11 第 19 條,✅ 已修(CM-2055,commit f85a82edb,1.21.0 出貨)。
  • 問題 3(Word 壓縮炸彈)=M11 第 24 條,✅ 已修(CM-2181,commit 48638cbdb/套件 2cb9d2cd,1.21.0 出貨)。
  • 問題 4(每套安裝同一組管理員密碼):🚫 裁定不修(M17 第 7 條)。
§3

找到什麼:4 個問題

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 建議
1 🔴 高 Word 匯入蓋掉既有合規範本那條路,從頭到尾沒檢查你是誰——只確認「這個範本存在嗎」,不問「你有沒有資格改它」 任何登入者(含唯讀稽核員)可以把別家公司或原廠公版的合規範本整份清空重寫,底下的控制項實作、元件、人員、盤點項目連鎖刪除。動手前還能先讀:預覽畫面會把那份範本現有的內容一起回傳 ① 任何有效登入帳號(不需要任何功能權限、不需要是任何專案的成員)
② 知道目標範本的編號——列表端點也只驗登入,撈得到
app/oscal/service/ssp_docx_import_app_service.py:165(上傳時)
同檔 :335, :361(確認時)
補上與手動路徑同一道門
2 🟡 中 匯出的 Excel 會把使用者寫的字當公式執行——控制項現況描述若以 = 開頭,寫進 Excel 時會變成活的公式 下載這份 Excel 的同事打開檔案,Excel 會去跑那段公式。可以是把檔案內容偷傳出去,也可以是在對方電腦上執行指令 ① 攻擊者能寫控制項現況描述(專案負責人,或透過問題 1 的無權限路徑)
② 有人匯出並用 Excel 開啟
③ 對方按下 Excel 的「啟用外部內容」提示
app/oscal/service/ssp_control_impl_import_service.py:139(及 :163) 輸出前把開頭的 = + - @ 跳脫掉
3 🟡 中 上傳的 Word 檔只檢查壓縮後大小、不檢查解壓後大小(壓縮炸彈) 一個 15MB 的檔案解開後可以是好幾 GB,把處理這個請求的伺服器程序吃光記憶體打掛。反覆送幾次就能讓整個系統持續無法使用 任何有效登入帳號 app/oscal/service/ssp_docx_import_app_service.py:759(限制只在 :131,20MB 壓縮後) 解析前先查解壓後總大小與膨脹比
4 🟡 中
⚠️ 範圍外
每套安裝的最高管理員密碼都一樣(密碼雜湊寫死在安裝腳本裡) 任何一套安裝的檔案外流,推出那一組密碼後,可以登入所有沒改過密碼的客戶系統,拿到繞過客戶隔離的最高權限 ① 拿得到程式碼倉庫或任何一份安裝包
② 推出明文密碼
③ 目標系統裝好後沒改過密碼
scripts/init/06-admin.sql:41-42 已知項,O1 已報過

§4

詳細說明

問題 1:蓋掉別人的合規範本,不需要任何權限(唯一的「高」)

白話:Word 匯入在上傳那一刻會先問「你要寫到哪裡去?」,然後分三條路走。三條路的把關鬆緊完全不同:

# app/oscal/service/ssp_docx_import_app_service.py:143-167
if source_type == "ssp":
    # 路 1:寫進專案的系統安全計畫
    self._require_project_manager(source_uid)          # ✅ 要是該專案的負責人
elif is_preselect_new:
    # 路 2:建一個新的合規範本
    if not viewer_has_capability("module-frame.create"):
        raise ForbiddenError(...)                      # ✅ 要有「建立範本」的權限
else:
    # 路 3:蓋掉一個既有的合規範本
    self._resource_library.get_resource_library(source_uid)   # ❌ 只確認「它存在」

第三行那個 get_resource_library,首腦開檔確認過它整支只做一件事:查不到就報「找不到」,查得到就把資料回傳。沒有任何一行在問「你是誰、你有沒有資格改它」:

# app/oscal/service/resource_library_app_service.py:205-216
def get_resource_library(self, uid: str, locale=None) -> dict:
    row = self._rl_query.get_resource_library_row(uid, locale=locale)
    if row is None:
        raise NotFound(...)        # ← 只有這一個判斷
    ...
    return dto                     # ← 沒有任何權限檢查

而改同一份資料的正規入口是有要求權限的——首腦開檔核對過手動建立/複製範本那三支端點,全部掛著 @require_capability:

api/module_frame/routes/module_frame_route.py:56  @require_capability("module-frame.create")
api/module_frame/routes/module_frame_route.py:72  @require_capability("module-frame.update")
api/module_frame/routes/module_frame_route.py:95  @require_capability("module-frame.create")

同一份資料、同一種破壞力,走正門要權限、走 Word 匯入不用。

確認階段有第二個更麻煩的地方。 確認匯入時,系統允許使用者在確認的當下再指定一次目標編號:

# app/oscal/service/ssp_docx_import_app_service.py:335
effective_source_uid = job.source_uid or payload.get("source_uid")

意思是:上傳時可以什麼都不填(走路 2「建新範本」,只需要「建立範本」權限),到了確認那一步再塞一個別人的範本編號進去。上傳時通過的是「建立」的門,實際做的卻是「覆寫別人」的事。

破壞到什麼程度? 覆寫走的是「先清空、再重建」:

# 同檔 :378-387
self._oscal_io.clear_ssp_body(template_ssp_id)   # ← 整份清掉
counts = self._oscal_io.import_ssp(merged, ...)  # ← 換成攻擊者的內容

clear_ssp_body 會連鎖刪掉控制項實作、實作敘述、對應元件、元件本身、繼承授權、盤點項目、人員與角色。攻擊者只要上傳一份近乎空白的合法 .docx,那份範本就等於被清空了。

還能先讀再毀。 上傳完成後的預覽端點(GET /ssp-docx-import/<編號>)會把解析結果對著目標範本的現況做比對,順便把那份範本現有的實作描述一起回傳。換句話說,這條路同時是讀取漏洞——正常要看那份範本的內容是需要「讀取範本」權限的。

攻擊步驟(不需要任何特殊工具):

  1. 用任何帳號登入
  2. 呼叫範本列表端點撈出所有範本編號(這支也只驗登入)
  3. 上傳一份隨便的 .docx,指定 source_type=module_frame、source_uid=<別人的範本編號>
  4. 讀預覽——對方範本的內容就出來了
  5. 送出確認,decisions 給空陣列——對方範本被清空並換成你的內容

為什麼底下沒有第二道關卡擋住:存放這些資料的 oscal.* 那幾張表沒有設資料庫層的客戶隔離保護(只有解析工作那幾張表有)。所以一旦範本編號讀得到,寫入就不會再被攔一次。而「讀得到」的範圍包含原廠公版(scope='SYSTEM')與分享出來的範本(scope='SHARED'),這兩類跨客戶都看得到。

這與 O3a(Excel)是同一個病。 O3a 抓到 Excel 匯入的同一條去路也沒有權限檢查(ssp_excel_import_app_service.py 的 _verify_source_exists,module_frame 分支)。首腦開檔確認過那支的註解寫著「權限由 RBAC middleware 處理」——但那層 middleware 並不存在於這條路上,路由只有 @jwt_required()。兩條線應該在同一次修正裡一起補,不要只補一邊。


問題 2:匯出的 Excel 把使用者寫的字當公式執行

白話:系統匯出控制項現況的 Excel 時,直接把資料庫裡存的文字填進儲存格:

# app/oscal/service/ssp_control_impl_import_service.py:139
ws.cell(row=row_num, column=5, value=ov.get("desc") or "")               # E
# 同檔 :163
ws.cell(row=row_num, column=8, value=o.get("desc") or "")                # H

產生 Excel 的那套程式庫(openpyxl)有一個行為:填進去的文字只要以 = 開頭,它就會判定那是公式,寫進檔案時標成公式而不是文字。於是「使用者打的一段描述」在別人的 Excel 裡變成一行會跑的程式。

出事的樣子:稽核員把某個控制項的現況描述寫成 =cmd|'/c ...'!A1,之後複核人員匯出這份 Excel、用 Excel 打開,Excel 會跳出「要不要啟用外部內容」,一旦按下去,那段指令就在複核人員的電腦上執行了。也可以改成把檔案內容偷偷送到外部網址。

為什麼是「中」不是「高」:Excel 從 2017 年起預設關閉這類外部連結,要使用者自己按下同意。但「同意」這個動作在企業內部的例行作業中很常被按掉,而且這份檔案是系統自己產生、寄給同事的,收件人的戒心比收到陌生附件低得多。

另外一個連動:問題 1 讓任何登入者都能寫入範本內容,等於也把這條路的「要先有寫入權」前提降到最低。兩條串起來,一個沒有任何權限的帳號可以間接在複核人員的電腦上放公式。


問題 3:上傳的 Word 檔沒有解壓後大小限制(壓縮炸彈)

白話:系統對上傳的 .docx 只檢查壓縮後大小不超過 20MB:

# app/oscal/service/ssp_docx_import_app_service.py:50, 131
_DOCX_MAX_SIZE = 20 * 1024 * 1024  # 20MB
if file_size > _DOCX_MAX_SIZE:
    raise BadRequestError(...)

但 .docx 本質是一個壓縮檔。裡面的 word/document.xml 如果是大量重複的內容,壓縮比可以到一千比一——15MB 的檔案解開來是十幾 GB。系統接下來會把整份解開並全部載進記憶體解析(:759 的 _Doc(file_path)),沒有任何一道關卡檢查解開後有多大。

出事的樣子:處理這個請求的伺服器程序記憶體一路往上爬,被作業系統強制終止。同一個程序上其他人正在處理的請求也一起陪葬。 每分鐘送幾次,系統就持續不可用。

這條與 O3a 同源:O3a 已經在 Excel 那條抓到一模一樣的問題(那邊限制是 10MB)。兩邊的修法一樣:解析前先用壓縮檔工具讀出「解開後總大小」,超過合理上限就直接拒收。


問題 4:每套安裝的最高管理員密碼都一樣(範圍外,已知)

安裝腳本裡把最高管理員 admin 的密碼雜湊寫成固定值:

-- scripts/init/06-admin.sql:41-42
c_password CONSTANT text := '$2b$12$yQu0...';
c_salt     CONSTANT text := '$2b$12$yQu0...';

意思是每一個客戶裝出來的系統,最高管理員的密碼都是同一組。這個帳號的權限會繞過「每個客戶只看自己資料」的隔離機制,而且設計上不可刪除、不可停用、不可降級。

這條 O1 已經報過,本棒的工具沿相依關係又撿到一次。不另計、不另開卡,列在這裡只是說明本棒的四條發現裡有一條是重複的。


§5

派工時的預判,哪些成立哪些不成立

卡片列了六個「重點看什麼」。逐條交代:

派工時的預判 結果
逐支公開方法對照守門,注意「守門藏在私有方法裡」 ✅ 成立,而且是本棒的高風險。三條去路裡兩條有守、一條沒有,與 Excel 同型
Word 有沒有對應 Excel 的檔案大小上限 ⚠️ 有,但不夠。_DOCX_MAX_SIZE 是 20MB,管的是壓縮後大小,擋不住壓縮炸彈
source_uid 由使用者送上來,有沒有檢查是不是他自己的 ✅ 成立,而且比預期糟。不只上傳時沒檢查,確認時還允許再換一個目標編號
「只匯入控制項實作」那條獨立入口的守門跟主匯入是不是同一套 ❌ 這條反而是好的。開檔核對過:匯出/驗證/重驗要求 require_participant、確認匯入要求 require_manager,四支公開方法全部有守。這條入口的問題不在權限,在匯出內容的跳脫(問題 2)
party_match_enrich.py:人名對不到時怎麼辦、能不能把別家公司的人對進來 ⭕ 工具沒報,首腦也未逐行復核。這支只有 78 行且是預覽用途不落資料,風險相對低,但本棒未能給出「已確認安全」的結論——若要確認,需另行指定
檔名處理與暫存檔殘留 ⭕ 工具沒報。開檔時看到暫存檔在解析失敗路徑上有 _safe_unlink 清理,但未做完整復核

§6

執行概況

項目 內容
掃描範圍 9 支檔/1,950 行(Word 上傳解析鏈,主專案側)
工具 Claude Code 官方 claude-security plugin v0.11.0
設定 --effort low(單一研究員掃全範圍+三人面板)/focus=attack-surface(跳過測試碼與第三方套件)
掃描起點 commit 7ce62104(branch feature/review)
派出/回報 agent 14 派 14 回,零失敗、零重試(agents_error: 0)
候選發現 6 條,去重後 4 條
面板 ✅ 完整跑完。4 條候選 12 票全投、零漏投、全部 3:0
驗證章 verified(工具自行依票數計算,非人工填寫)
耗時 2 小時 0 分(7,194 秒)
首腦復核 高風險那條逐行開檔核對過(路由守門、get_resource_library 全文、手動路徑的 @require_capability、確認階段的覆寫邏輯),其餘三條核對過程式碼位置與行號

§7

建議的修正方向

問題 1(高)優先,而且要和 O3a 的 Excel 那條一起修——兩條是同一個病的兩個入口,只補一邊等於沒補:

  1. Word 與 Excel 匯入「覆寫既有範本」那條分支,補上與手動路徑同一道門(module-frame.update)
  2. 確認階段要重新檢查一次,不要相信上傳時的判定
  3. 拿掉「確認時可以再指定目標編號」這個設計,或者至少驗證新指定的目標是呼叫者有權寫入的

問題 3(中)也建議與 Excel 那條一起修——同一個解法,兩邊各加一道解壓後大小檢查。

問題 2(中) 是獨立的一條,修法是輸出 Excel 前把開頭的 = + - @ Tab 換行 跳脫掉;同樣的處理要套到共用的範本匯出程式(app/module_frame/service/module_frame_template_import_service.py)。

問題 4 已有既有工單涵蓋,不另開。


§8

這一棒沒有涵蓋的

  • Word 內容抽取器本身(domain/oscal/parser/ 7 支)——在 O7a,風險型態是「惡意檔案」
  • 兩條匯入線共用的接縫檔 _common.py(620 行)——在 O9
  • 人員對帳(party_match_enrich.py)與暫存檔清理——工具沒報,首腦也未逐行復核,見上表
  • 這是 low 檔次的一輪掃描:一個研究員完整看過整個範圍,不是完整體檢