FR-177.A7 掃描報告:LDAP 登入、離線操作者、來源網段白名單(CM-3514)

⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。

1. 🔴 一句話結論

找到 2 個問題,沒有高風險。最嚴重的一條(中風險):只有「設定」權限的人,可以把 LDAP 改成指向自己架的假伺服器,然後冒充名單上任何一位權限更大的操作者登入主控台。

2. 這一棒在檢查什麼

主控台(客戶在自己機器上用瀏覽器開的 Agent 管理頁,8444 埠)除了本機管理員帳號,還有兩條登入路:

  • 用客戶自己的 AD/LDAP(公司帳號系統)帳號登入。
  • 主系統事先推下來的「離線操作者」名單:主系統斷線時,誰還能登入這台。

另外還有一張「只准哪些網段連進 8444」的白名單。這一棒看三件事:門有沒有關好、客戶 AD 服務帳號的密碼有沒有保管好、名單會不會被人塞人。

3. 掃到什麼:總覽表

# 嚴重度 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修(摘要)
F1 中 只要有「設定」權限,就能把 AD 登入改成問攻擊者自己的伺服器,等於讓他決定誰的密碼「對」 能冒充權限更大的操作者:下載證據檔、裝更新、重啟,稽核紀錄記在被冒充的人頭上;別的操作者用 AD 登入時,密碼也會被他收走 ① 自己已經有主控台「設定」權限 ② 名單上有權限比他大的人,且離線登入開著 ③ 他架的 LDAP 伺服器 Agent 連得到 guidant_agent/console/api/ldap_routes.py:79、:94;驗密碼在 guidant_agent/identity/adapters/ldap_source.py:249-253 改登入來源與本機 LDAP 設定改成最高權限(只准本機管理員);來源一改就踢掉所有 AD 登入的 session
F2 低 主系統拿掉某位操作者的部分權限(人還在名單上)時,他正在用的登入狀態仍保有舊權限 被收回的權限只要他一直在用就持續有效,重開機也還在 ① 他本來就是合法操作者、正在登入中 ② 主系統只縮權限、沒整個撤銷 guidant_agent/identity/app/offline_operators.py:133(replace_all) replace_all 也回傳「權限變少」或「AD 綁定改了」的人,讓 apply 一併踢掉

為什麼 F1 是中、不是高:攻擊者必須先是有「設定」權限的自己人,不是外人打得進來。但它打破了「只有設定權的人不能重啟、不能下載」這條權限切分。

4. 每條發現詳述

F1 — 有「設定」權限就能自己指定 AD 伺服器,冒充別人登入(中)

白話:AD 登入時,Agent 會拿你輸入的密碼去問 AD 伺服器「這組密碼對不對」,伺服器說對就放行。問題在於「去問哪一台伺服器」,只要有「設定」權限就能改(PUT /console/api/ops/ldap/local 填伺服器位址,PUT /ldap/source 選「本機自設」)。改成攻擊者自己架的 LDAP 後,他想讓誰的密碼「對」就讓誰的對。

出事會怎樣:

  1. 攻擊者在自己的 LDAP 建一個帳號,名字跟名單上某位管理員的帳號(sub)一樣,密碼自己設。
  2. 他到 Agent 登入頁的 AD 分頁,用那個名字和自己設的密碼登入。
  3. 假伺服器回答「對」,Agent 照名單給他那位管理員的權限。

另外,真的操作者之後用 AD 登入時,輸入的密碼會被送到假伺服器(可以設成「不加密」),密碼就被收走了。

要先有什麼才打得到:見總覽表。三個條件都要成立。

在哪裡:

  • guidant_agent/console/api/ldap_routes.py:77-85(改來源)與 :88-97(存本機設定):只檢查 configure。
  • guidant_agent/identity/adapters/ldap_source.py:248-253:非 AD 模式下直接拿名單上的 op.sub 當帳號,去問設定好的那台伺服器。

怎麼修:在 ldap_routes.py 的 put_ldap_source、put_ldap_local、delete_ldap_local 把權限從 require_session("configure") 提高到只准本機管理員(或要求持有全部主控台權限)。同時在來源或位址改動後,呼叫 console_session 踢掉所有用 LDAP 方式登入的 session。

人工核對:已開檔確認。三個路由都只掛 require_session("configure")。_validate(ldap_routes.py:55-62)只檢查埠號與格式,沒限制位址。檔頭第 4 行自己也寫「選錯來源或填錯伺服器,下一個人登入時 AD 密碼就送到那台機器」,表示設計者知道風險,但把它和「設定」權限綁在一起。成立。

F2 — 權限被縮小後,正在用的登入狀態不會跟著縮(低)

白話:主系統每次心跳(Agent 定期跟主系統報到)都會推下最新名單。Agent 只把「被撤銷」或「從名單拿掉」的人踢下線(offline_operators.py:133、:142-144)。權限是在登入那一刻抄進登入狀態的,之後不會再對名單。所以只縮權限的人,舊權限會一直留著。

出事會怎樣:主系統拿掉某人的「下載檔案」權限,但他已登入的分頁仍能下載。登入狀態每 30 分鐘會自動延長,重開機也不會消失。

要先有什麼才打得到:他本來就是合法操作者且正在登入中。危害有限,所以列低風險。

在哪裡:guidant_agent/identity/app/offline_operators.py:120-133(replace_all)、:136-144(apply)。

怎麼修:在 replace_all 讀出舊列時比對 scope,新 scope 不是舊的超集合、或 ad_account 不同時,把該 sub 一起放進回傳集合;apply 就會照原本的迴圈呼叫 revoke_by_sub。

人工核對:已開檔確認回傳式只有撤銷與移除兩種,沒有比對 scope。成立。

被三個檢查員一致否決的一條(供參考)

「測試連線」端點收前端送來的整組設定,可以叫 Agent 連任意內網位址。三票都認為不成立:能叫這個端點的人本來就有權設定任意伺服器,多這條路並沒有多拿到東西。而且改了位址、加密方式或埠時,存著的密碼不會跟著送出去(PINNED_KEYS,ldap_source.py:50,要重填)。根本問題其實是 F1。

5. 「重點看什麼」逐條人工查證

項目 人工結果 說明
ldap_source.py:206-207 服務帳號密碼存哪、回畫面有沒有遮 不成立(做得對) 存在 credential_store(Fernet 加密,經 _secrets() → SecretFieldStore),不是明文 JSON。畫面一律走 secret_form.masked(ldap_routes.py:32、:35)。:9 的「密文會被 AD 反鎖」問題的解法:心跳收到 gAAAAA 開頭的密文就把該欄丟掉(_sanitize,:164-166),只收明文(走 mTLS)。
verify_user 帳號 filter escape、空密碼匿名 bind、encryption="none" 前兩項不成立(已防);第三項部分成立 Agent 沿用 jedi-ldap kernel 庫(ldap_source.py:20),也繼承了 FR-075/CM-1560 的修正:jedi_ldap/infra/directory.py:158 空密碼直接拒絕;:224、:260 搜尋前用 escape_filter_chars 轉義。「不加密」仍是可選項(:48、:63,同步模式舊設定空值也當不加密,:159),連線結果只給 PLAINTEXT_WARNING、不擋。正式環境允許明文是設計選擇,但配合 F1 會讓密碼被收走更容易,建議修 F1 時一併評估是否禁止自設模式選明文。
ldap_routes.py 測試連線收整組設定 → 內網探測、密碼送到假 LDAP 不成立(附註) 確實收前端整組設定(:51 merged)。但改了 server/port/encryption 時,舊密碼不會沿用(PINNED_KEYS),所以不會把客戶的 AD 密碼送出去。內網探測需要「設定」權限,而這些人本來就能設伺服器。三個檢查員一致否決。
make_guarded_server 判來源用 TCP 還是 header;env 格式錯;下發格式錯生效哪組;能否放寬底線 不成立(做得對) 用的是 socket.accept() 回的 TCP 位址(allow_cidr.py:180-181),不看 HTTP header,而且在 TLS 握手前就擋。env 格式錯時只放行本機(:38-42,出錯預設擋下來)。主系統下發格式錯時 return 不呼叫 _set_cloud(:76-80),沿用上一份已生效的收緊值(或沒收過時用底線),不會退回比較寬。intersect 只留落在底線內的那段(domain/allow_cidr.py:39-46),底線不空時無法放寬。例外:底線為空(安裝時沒設=不限)時,主系統下發的照單全收(:59-60),這是檔頭寫明的設計。
offline_operators.py:139-182 名單驗來源、ad_bindings 被改、RECEIVED_FILE 權限 部分成立 名單沒有自己的簽章,信任只來自心跳通道(mTLS+https,見 sync 規則);主系統被打穿就能塞人,這是架構上的信任根選擇,不在本棒範圍內修。ad_bindings 來自同一份名單,Agent 本機沒有改綁定的路,綁定改了也不會讓 AD 使用者對到別人的 scope:撞到兩人以上一律當找不到(:186)。但綁定改了不會踢掉舊 session(同 F2 的根因)。RECEIVED_FILE 用一般 open(tmp,"w") 寫(:101),權限跟著 umask、沒有明確設 0600;內容只有收到時間、不含祕密,影響極小。
source_cidr_guard 只有 8444 用?8443、8000 呢 不成立(刻意) 全 repo 只有 files/api/browser_plane.py:216(8444)呼叫。8443 是資料面,走 mTLS,客戶端憑證必驗,只有主系統拿得到憑證;白名單是給瀏覽器面用的。8000 不在本棒範圍,未深查。

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

  • 「這 2 條存在嗎」:可信。 每條都經三個獨立檢查員(分別從「打不打得到」「傷害多大」「有沒有別的防線」三個角度看)投票,6 票全數成立,我也開檔核對過。
  • 「只有這 2 條嗎」:不能保證。 這次是最省的模式(一位研究員讀完 6 個檔),只讀程式碼、沒有實際執行或攻擊。範圍外的呼叫端(例如 local_auth.py、session.py)只在追 F1、F2 時順帶看過。同一份程式再掃一次可能找到不同的東西。

7. 執行概況(工程師看)

項目 值
Workflow run ID wf_7a9459b4-6fc
掃描版本 3813abd(branch ws-CM-3507-CM-3514,工作區乾淨)
範圍 6 檔(file_count: 6),effort low
verification.status verified
reason_kind 無(null)
candidates 3(去重後 3)
panel_votes 9(三個檢查員對 3 條各投一票,9 票全數投出,沒有漏投)
確認成立 2(F1、F2 皆 3/3);否決 1(0/3)
researchers_dispatched / returned 1 / 1
耗時 約 51 分鐘(10 個 agent,約 63 萬 token)
原始產物 docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-155942/