H1 檢查結果:主專案接上「系統設定核心」的那一段

H1 檢查結果:主專案接上「系統設定核心」的那一段

檢查日期 2026-09-15|耗時 13 分 10 秒|對應卡片 CM-1805|檢查範圍 25 個檔案


§1

🔴 一句話結論

只有一件新問題,但它很嚴重:一個客戶公司的系統管理員,可以改掉「全部客戶+我們自己」共用的登入規則——甚至可以把全體員工的登入驗證來源指到他自己的機器上。 另外掃到的那一條不是新的,是上一棒就已經登記過的同一件事(總表第 72 項),只是這次從另一側又撞到一次。


§2

這一棒在檢查什麼

系統裡有一塊「系統設定」的功能:管理員在畫面上調整寄信伺服器、登入規則(要不要雙因子、密碼打錯幾次鎖定)、檔案要存去哪裡、要不要接公司的帳號目錄(LDAP)等等。這塊功能的內臟放在一個共用套件裡(jedi-system-core),而主專案負責把它接到產品上——開出 API、掛上權限檢查、決定哪些欄位要遮起來不給看。

這一棒檢查的就是「接線」這一段的 25 個檔案:誰能讀這些設定、誰能改這些設定、改下去會影響到誰。

上一棒(P1)檢查的是套件內臟那一半(24 個檔),已經驗收完,找到 2 條。這一棒是同一個功能的另一半。


§3

掃到什麼:總覽表

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 狀態
F2 🔴 高 **一個客戶公司的管理員,改到的不是自己公司的登入規則,而是「所有人共用的那一份」 他可以幫全部署的每一個人**(包含我們自己的平台管理員)關掉雙因子驗證、把「密碼打錯幾次就鎖定」改成永不鎖定、把登入憑證有效期從 5 分鐘延長到 1 個月 ① 任一客戶公司的系統管理員帳號
② 該帳號不必特別申請——新客戶開通時系統就自動發這個權限
端點 api/system_config/routes/security_policy_route.py:31(PUT 在 :59)
寫入 app/system_config/service/security_policy_app_service.py:162
繞隔離 infra/system_config/system_config_root_reader.py:136
🆕 新增
F2-B 🔴 高
(比 F2 更該先修)
同樣的手法,可以把「全公司登入時去哪裡驗證帳號密碼」(LDAP 伺服器位址)改成攻擊者自己的機器 登入規則被改壞,還看得出來;驗證來源被掉包,等於直接接管所有人的登入——員工照常輸入帳密,帳密卻是送到攻擊者的機器上 同 F2(ldap-config.update/smtp-config.update 這兩個權限一樣自動發給客戶管理員) app/system_config/service/guarded_system_config_service.py:104 _write_shared_root_config() 🆕 新增(與 F2 同一條記,修法相同)
F1 🔴 高 讀取系統設定的那支 API 忘了檢查權限,任何登入者都讀得到檔案儲存的帳號密碼 拿得到檔案倉庫的帳密就能繞過系統,直接讀走/改掉/刪掉所有上傳的證據檔與文件 任何一個能登入的帳號(不需要任何權限) api/system_config/routes/system_config_route.py:131 ♻️ 不是新的——就是跨 arc 總表已登記的第 72 項(上一棒 P1 查到的)。這次帶回兩個新細節,補進第 72 項

淨結果:工具報 2 條,扣掉 1 條重複,真正新增 1 條(高風險)。問題沒有變多,但新增的這一條很重。


§4

每條發現的詳述

F2:客戶的管理員改到的是「全公司共用的那一份」登入規則(新增)

現況:已修(M22-1,CM-2054);後續「誰算總部」判斷的缺口另由 M22-4 修正(CM-2202,1.21.0 出貨)

白話說明

系統裡有一份「登入安全政策」——要不要雙因子驗證、密碼打錯幾次鎖帳號、鎖多久、登入憑證多久過期。直覺上每個客戶公司應該各有一份、各自調整。

但實際上整套系統只有一份,而且每個客戶公司的系統管理員都改得動它。

他打「更新登入安全政策」這支 API,程式確實有檢查「你有沒有改登入政策的權限」——他有。但接下來寫入的時候,程式刻意關掉了「每個客戶只能碰自己資料」的資料庫隔離機制,把值寫進總部那一份。於是:

  • 把「要雙因子驗證」關掉 → 所有人(包含我們的平台管理員)登入都不用第二道驗證
  • 把「密碼打錯幾次鎖定」改成 9999 → 等於永不鎖定,可以慢慢猜密碼
  • 把登入憑證有效期從 5 分鐘改成 30 天 → 憑證被偷走之後,攻擊者可以用一個月

在哪裡

角色 檔案:行號 這一行在做什麼
端點守門 api/system_config/routes/security_policy_route.py:31 _update = require_capability("security-policy.update")——只檢查「有沒有這個權限」,沒有檢查「是不是平台管理員」
更新入口 api/system_config/routes/security_policy_route.py:59 PUT 方法(外層 :57 只要求已登入)
實際寫入 app/system_config/service/security_policy_app_service.py:162 update_policy()
繞過客戶隔離 infra/system_config/system_config_root_reader.py:136 write_root_config_value();其中 :179 明文寫 SET LOCAL app.is_super_admin = 't'(關掉隔離),並把寫入對象釘死成總部那一列

首腦實查:檢查員答不出來的那一題

三位檢查員一致認為這條成立,但把「可信度」停在 medium(中等),卡在一個他們光讀程式碼答不出來的問題:「新開的客戶,預設的管理員角色到底有沒有拿到這個權限?」 如果沒拿到,這條就打不出來。

首腦到開發環境的資料庫唯讀查了,答案是「有,而且是自動拿到的」:

① 誰實際持有這個權限——security-policy.update 這個權限被標成「非平台層」,9 個客戶公司裡有 8 個非總部公司的管理員角色實際持有它:

客戶公司 持有的角色
1 Guidant.AI Administrator
102 Billows Tech Billows Admin
131 歐洲航空 歐洲航空系統管理員
152 租戶A/153 租戶B/158 JEDI/162 租戶C/163 租戶D/164 租戶E System Manager

② 全域那一份真的只有一份——開發環境的登入政策設定共 7 筆,全部屬於總部(編號 1):是否要雙因子、登入鎖定次數、鎖定時間、憑證有效期、憑證續期有效期、密碼多久要換、多久沒登入算閒置。「一份供全體使用」屬實。

③ 那一份真的被全站吃下去——core/app_factory.py:148 服務啟動時就把它讀進全域設定;core/plugins/identity.py:141 判斷「這次登入要不要雙因子」時直接讀它。

④ 為什麼每個新客戶都會自動拿到(根因)——jedi_iam/app/service/tenant_provisioning_service.py:120-131:新客戶開通時,系統把「所有權限」扣掉標記為「平台層」的那些之後,其餘全部發給該客戶的預設管理員角色。所以任何被標成「非平台層」的權限,新客戶一開通就自動有,不需要有人去手動指派。

→ 檢查員唯一不確定的前提,在開發環境成立。首腦把可信度從 medium 調到 high(高),嚴重度維持「高風險」。


F2-B:同樣手法可以掉包「全公司的登入驗證來源」(新增,比 F2 更該先修)

現況:已修(M22-1,CM-2054,與 F2 同一套修法)

這一半工具只順帶提了一句,但首腦查證後認為必須獨立講,而且應該排在 F2 前面修。

白話說明

很多公司的員工帳號不是存在我們系統裡,而是存在公司自己的帳號目錄伺服器(LDAP)。員工登入時,我們的系統會拿他輸入的帳號密碼去問那台伺服器「這個人對不對」。

「那台伺服器在哪裡」這個設定,跟 F2 一樣是全體共用一份,而且改它的權限(ldap-config.update)同樣被標成「非平台層」,同樣自動發給那 8 個客戶公司的管理員。寄信伺服器設定(smtp-config.update)也是一樣的形狀。

意思是:一個客戶公司的管理員,可以把「全公司登入時去哪裡驗證帳密」指到他自己架的機器上。 之後所有人照常輸入帳號密碼,帳密卻是送到攻擊者手上;他也可以讓自己的機器對任何帳密都回答「對」,直接以任何人的身分登入。

為什麼它比 F2 更急

登入規則被改壞,事後看得出來(大家會發現雙因子不見了);驗證來源被掉包,是無聲的接管——畫面完全正常,員工不會察覺,而攻擊者拿到的是全公司的明文帳號密碼。

在哪裡

  • 寫入路徑:app/system_config/service/guarded_system_config_service.py:104 _write_shared_root_config()
  • 落點與 F2 相同:總部那一列
  • 權限:ldap-config.update、smtp-config.update,兩者同樣是「非平台層」,同樣發給那 8 個客戶公司(首腦同一次查詢確認)

F2/F2-B 怎麼修(三個方向,需要決策者選)

現況:已依決策者裁定修正(M22-1,CM-2054);以下為掃描當時列的三個方向

好消息是不用造新東西——「只有平台管理員才能做」這道守門已經存在、現成可用:common/authz 對外提供的 require_platform_admin_route(實作在 jedi_iam/authz/decorators.py:29 與 jedi_iam/authz/platform.py:25)。

方向 做法 差別
1. 加掛平台管理員守門 在登入政策與共用設定(LDAP/寄信)的寫入路徑上,除了現有的權限檢查,再加一道「必須是平台管理員」 最直接,改動最小
2. 限縮寫入對象 不再一律寫進總部那一列,改成「只寫呼叫者自己公司的那一列」,只有總部公司才能編輯總部那一列 保留客戶自己調整的能力,但要處理「客戶那一列目前不存在」的情況
**3. 把這三個權限改標成「平台層」 把 security-policy.update、ldap-config.update、smtp-config.update 標記為平台層 治本**——改標之後新客戶不會再拿到;但已經發給 8 個客戶的授權仍然存在,要另外清掉

⚠️ 三個方向都要決策者拍板,因為它們會改變一件產品行為:「客戶自己到底能不能調整自家的登入強度」。這不是純技術選擇。


F1:讀取設定的 API 忘了檢查權限(♻️ 這條不是新增的)

現況:已修(M22-2,CM-2063)

🔴 先講清楚:這條不是新問題

這條就是跨 arc 總表第 72 項,上一棒(P1)就已經登記過的同一件事。 第 72 項原文已經明白列出三個入口,其中就包含主專案的 api/system_config/routes/system_config_route.py:131。H1 只是從主專案這一側再撞到同一件事。問題沒有變多,不要重複計數、不要重複開修正卡。

這是什麼問題

「讀取某一組系統設定」的那支 API,只檢查「你有沒有登入」,沒有檢查「你有沒有權限看這個」。任何一個能登入的帳號——包括權限最低、完全不該碰設定的專案成員——都能讀出檔案儲存的位址、桶名、帳號與密鑰。

而這組帳密是安裝程式寫進去的、這台機器真正的檔案倉庫憑證,而且新客戶開通時會原封不動複製一份。拿到之後可以繞過整個系統,直接讀走、覆寫或刪掉倉庫裡所有上傳的證據檔、SSP 文件與檢測報告。

在哪裡(含一處行號更正)

api/system_config/routes/system_config_route.py:

第 131 行  讀取 get(self, group, key)   只有「要登入」            ← 沒有檢查權限
第 146 行  更新 put(...)                有檢查「更新設定」的權限
第 158 行  刪除 delete(...)             有檢查「刪除設定」的權限

同一支檔案裡的「還原出廠設定」功能:
第 103 行  讀取 GET                     有檢查「讀取儲存設定」的權限   ← 連唯讀的都有
第 111 行  執行 POST                    有檢查「更新」的權限

同一支檔案裡,唯獨這一支讀取沒有權限檢查,寫入的三支都有,連隔壁那支唯讀的「還原出廠設定」都有。這證明是漏掉,不是刻意設計。

⚠️ 行號更正:工具原始報告寫 :134,那是函式內部呼叫服務的那一行;真正缺守門的是 :131 的函式本身。本報告與總表第 72 項一律寫 :131,開工單時兩份文件才不會各指一處。

H1 帶回兩個第 72 項沒有的新細節(要補進第 72 項)

新細節 1:同一個沒守門的入口,吐出來的不只是檔案倉庫帳密。 還包括登入安全政策(PASSWORD_POLICY/CONFIG)與閒置逾時設定(WEB_IDEL_CONFIG)——等於把「這套系統的登入防護設定成什麼樣」整份攤給任何登入者看,是攻擊前的偵查材料。

新細節 2:遮罩只遮掉「密碼」一個欄位,其餘照樣外洩。 系統有一份「要遮起來不給看」的名單,但它只遮掉欄位名叫 secret 或 private_token 的值。所以同一列裡沒被遮的欄位一併外洩:

  • 寄信伺服器的位址、埠號、帳號、寄件人
  • LDAP 的位址、埠號、查詢起點、帳號、憑證內容

名單本身也有缺口——jedi_system_core/plugin/contract.py 的遮罩群組名單是 SMTP、THIRD_PARTY_LOGIN、NOTIFY_CONFIG、ISSUE_INTEGRATE_CONFIG,沒有檔案儲存設定;遮罩欄位名單是 secret、private_token,沒有物件儲存用的 access_key/secret_key。(主專案端 api/system_config/routes/system_config_route.py:81 的 _MASK_CONFIG 與 :84-86 的 _mask() 只是委派套件那份機制。)

怎麼修

修法隨第 72 項辦理,重點兩件事:

  1. 在 api/system_config/routes/system_config_route.py:131 的 get() 第一行補上 assert_config_capability(group, "read"),與同檔的更新、刪除一致。
  2. 獨立地把檔案儲存設定加進遮罩群組名單、把 access_key/secret_key/minio_access_key/minio_secret_key 加進遮罩欄位名單——這樣就算未來又漏一次守門,也不會直接變成帳密外洩。

§5

這份結果可信到什麼程度

「這兩條是真的嗎」→ ✅ 可信

  • 三位獨立檢查員對兩條發現各投一票,6 票全數投出,兩條都是 3 票全過,沒有漏投、沒有中斷、沒有任何一條被降低嚴重度。
  • 首腦沒有只採信工具:兩條都親自打開檔案逐行核對過,F1 的行號還因此更正了一處。
  • F2 的關鍵前提另有資料庫實查佐證(8 個客戶的管理員實際持有該權限、全域設定確實只有一份、新客戶開通時自動發放),可信度因此從中等調升到高。
  • 結果沒有過期:掃描後這 25 個檔案至今一個字都沒改(首腦比對掃描版本到目前的程式碼差異為空)。

「是不是只有這兩條」→ ❌ 不可信,三個理由

理由一:這是快篩,不是全面檢查。 用的是最低強度模式——一位研究員把 25 個檔案讀完就提報,不做元件盤點、不做威脅建模、不跑密鑰專項掃描。所有結論都出自「讀程式碼」,沒有實際打過任何 API、沒有跑過任何攻擊驗證。

理由二(最重要):卡片列了八個要追查的點,工具只碰了一部分。

卡片上的追查點 結果
⑤ 三支端點的權限守門不一致 ✅ 實質回答了——就是 F1
② 權限分流表有沒有映得太寬鬆 🔶 只碰到一半——F2 正是「權限層級標錯」的一個實例,但那張對照表本身沒有被逐項檢視
③ 六處「關掉客戶隔離」的查詢有沒有收窄 🔶 只碰到一半——F2 用到其中一處當作機制,但六處沒有逐支稽核
⑥ 遮罩名單在主專案是不是又建了第二份 🔶 只碰到一半——F1 碰到名單漏了檔案儲存設定,但「主專案有沒有第二份名單」沒查
① 四個地方繞過守門版服務 ⬜ 完全沒碰(auth_containers.py:180/notification_containers.py:20/user_change_password_containers.py:46/login_containers.py:14-15)
④ 那支「有就更新、沒有就新增」的寫入,兩個人同時打會怎樣 ⬜ 完全沒碰
⑦ 出廠快照的隱藏是不是滴水不漏 ⬜ 完全沒碰
⑧ 新客戶繼承設定會不會繼承到別家的 ⬜ 完全沒碰

🔴 ⬜ 的意思是「沒有被提出」,不是「查過沒問題」。 尤其第 ① 點是卡片自己標成「本棒最重要的一條」的,而它完全沒有被回答——下一任接手的人不要以為那四條路徑是乾淨的。

理由三:範圍只涵蓋一半。 這一棒只掃「主專案接線」這 25 個檔,選單字典那一棒(P2,33 個檔)還沒跑。現在還不能說「系統設定核心已經掃過了」。


§6

執行概況

項目 數字
檢查範圍 25 個檔案(與卡片逐檔對上)
檢查強度 最低(low),聚焦「攻擊面」
派出/回報的研究員 1 / 1
候選問題 → 去除重複 2 → 2
投票數 6(2 條發現 × 3 位檢查員),兩條都 3 票全過
沒投到票的 / 投票中斷的 / 被降低嚴重度的 0 / 0 / 0
驗證章狀態 verified(無拒收理由)
掃描當下的程式碼版本 commit 2b11bd54,branch main,工作區乾淨
耗時 13 分 10 秒(790 秒)

為什麼只跑 13 分鐘?(不是草草了事)

上一棒(P1)跑了 2 小時 39 分,這一棒 13 分鐘,差了一個數量級,看起來很可疑。原因是:

投票的成本 = 候選問題數 × 3 位檢查員。 P1 有 4 條候選要投,這一棒只有 2 條,時間自然短。流程一步都沒有跳過——驗證章是完整的 verified,6 票全投、沒有中斷、沒有未審的候選。

短的原因是「找到的東西少」,不是「檢查得少」。至於「找到的東西少」是不是等於「問題少」,看上面「是不是只有這兩條」那一段——答案是不能這樣推論。


📄 本報告由首腦依工具原始產物補寫(該棒 runner 未交付報告);掃描本身完整有效,缺的只是「寫成給人看的文件」這一步。