FR-187.B 掃描報告:Agent 回報事件(狀態、日誌、升級進度、主控台稽核)

1. 🔴 一句話結論

找到 3 個問題,都是低風險,而且都要先控制一台已註冊的 Agent 才打得到。最值得修的是第 3 條:被控制的 Agent 可以冒用主系統任何一個真實帳號的名字寫稽核紀錄。

2. 這一棒在檢查什麼

Agent 裝在客戶機器上,會透過 POST /agents/events 回報自己的狀態、錯誤日誌、升級進度與結果,以及主控台上有人做了什麼(寫進主系統稽核)。這一棒假設 Agent 已經被人控制,檢查主系統收下這些回報時信了多少:能不能改到不該改的欄位、冒用別人名義、謊報升級成功、或把主系統灌爆。

範圍 10 個檔、約 870 行(清單見卡片 CM-3715)。

3. 掃到什麼:總覽表

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 在哪裡 怎麼修 嚴重度
1 Agent 送的錯誤日誌,「訊息」如果不是文字(例如一個陣列),就不會被截到 8KB 上限,整包原樣存進資料庫 資料庫被塞大、日誌查詢頁與診斷包匯出讀到很大的列時吃記憶體變慢 控制一台已註冊的 Agent(拿得到它的通行證與簽章金鑰) domain/support/service/agent_log_domain_service.py:59-67 先轉成文字再截長度 低
2 事件去重用的「發生時間」與「工單編號」是 Agent 自己填的,長度沒限制,整串存進 Redis(主系統的快取伺服器)一天 Redis 記憶體被灌滿;Redis 也存著所有 Agent 的「防重放」紀錄,滿了會影響其他 Agent 連線 控制一台已註冊的 Agent;Redis 沒設記憶體上限 app/remote_agent/service/agent_event_dispatcher.py:92、:119 組去重鍵前限制長度與格式,或改成雜湊值 低
3 主控台稽核紀錄的「操作者」是 Agent 自己報的名字,主系統沒核對這個人真的有登入過那台 Agent 稽核報表可以出現「某管理員在這台 Agent 下載了檔案」但其實不是他;還能偽造本機管理員登入觸發告警信 控制一台已註冊、屬於該客戶的 Agent core/plugins/_agent_console_audit.py:97-105 Agent 每筆附主控台登入票的編號,主系統核對是不是發給這台、這個人的;核不到就標「Agent 自報」不換使用者身分 低

為什麼都是「低」不是「中」:三條的共同前提都是「Agent 已經被控制」。被控的 Agent 本來就能在它自己那台機器上做很多事;這三條的後果是讓主系統變慢或稽核紀錄失真,不會讓攻擊者讀到別的客戶資料,也拿不到主系統帳號權限。

4. 每條發現的詳述

第 1 條:非文字的日誌訊息不受長度上限

  • 白話:Agent 回報錯誤日誌時,每筆的「訊息」主系統會截到 8KB。但截斷只在「訊息是文字」時才做(:60);如果送一個陣列或物件,就跳過截斷,在 :67 被轉成文字整包寫進 public.agent_logs.message。
  • 出事會怎樣:每個請求最多可塞到請求大小上限(掃描員估約 110MB),保留 90 天;支援頁的「最近錯誤」與診斷包 CSV 匯出會把這些列讀進記憶體。
  • 要先有什麼:一台已註冊 Agent 的通行證與簽章金鑰。
  • 在哪裡:domain/support/service/agent_log_domain_service.py:59-67(to_rows)。
  • 怎麼修:在 to_rows 裡把 message 先轉成 str(或非文字直接丟掉),再套 message_max 截斷。
  • 首腦核對註記:已開檔核對,:60 的 isinstance(message, str) 判斷與 :67 的 str(message) 確實如描述。

第 2 條:去重鍵長度沒上限,可灌爆 Redis

  • 白話:主系統用「Agent 編號+事件種類+工單編號+發生時間」當去重鍵(:92),處理成功後存進 Redis 24 小時(:119)。工單編號與發生時間都來自 Agent 送的內容,協定只檢查「是字串」,沒限長度。agent_status、agent_logs、console_events 三種事件的處理都沒檢查這兩欄(升級事件有用 clean_job_id 檢查工單編號、event_time 解析時間,但要等處理成功才寫鍵,格式錯會先 400,所以升級事件不受影響)。
  • 出事會怎樣:送一個空的 agent_status、附超長的發生時間,每次換值就多一個大鍵,一天內不消失,Redis 記憶體可被填滿;Redis 同時存著所有 Agent 的防重放紀錄(DPoP,就是防止別人把攔到的請求重送一次的機制)。
  • 要先有什麼:一台已註冊 Agent 的通行證與簽章金鑰;Redis 沒設記憶體上限與淘汰規則。
  • 在哪裡:app/remote_agent/service/agent_event_dispatcher.py:92(組鍵)、:119(寫入)。
  • 怎麼修:在 parse 或 dispatch 組鍵前,要求發生時間是 64 字以內且 fromisoformat 解得開的時間、工單編號符合 _JOB_ID_RE;或把這兩段做雜湊後再組鍵。
  • 首腦核對註記:已開檔核對,組鍵與寫入行號正確。

第 3 條:稽核紀錄的操作者可以被 Agent 冒用

  • 白話:Agent 主控台有人登入、改帳密、下載檔案時,Agent 送 console_events 給主系統寫稽核。每筆的 actor(操作者帳號)是 Agent 自己填的,主系統在 :102 直接把它當成這筆稽核的使用者,還去查真實使用者的暱稱(:148)。中間沒有核對「這個帳號真的拿過主系統發給這台 Agent 的主控台登入票」。
  • 出事會怎樣:被控的 Agent 可以讓稽核報表顯示「客戶管理員在這台 Agent 下載了檔案/改了帳密」,嫁禍給真實的人,真正動手的人也可以推說不是他;偽造本機管理員登入事件還能隨時觸發寄給客戶管理員的告警信(:152)。
  • 要先有什麼:控制一台屬於該客戶的已註冊 Agent。只能寫進它自己那個客戶的稽核(租戶只認通行證),不能寫到別家客戶。
  • 在哪裡:core/plugins/_agent_console_audit.py:97-105(_write)、:148(查暱稱)。
  • 怎麼修:Agent 每筆有操作者的事件附上主控台登入票的編號(jti)或簽過章的票內容;主系統核對該票是發給這台 Agent、票上的人就是 actor,才換成那個使用者寫稽核。核不到時不換使用者身分,改在稽核列標「Agent 自報:」。
  • 首腦核對註記:已開檔核對,:98-102 確實直接取 event.get("actor") 設進 user context,沒有任何票據核對。

卡片「重點看什麼」逐條人工查證

# 重點 人工查證結果
1 去重鍵用 Agent 自報的 at、job_id,換值就能無限重送;有沒有頻率與大小上限;handler 冪等夠不夠 部分成立。身分與租戶只取通行證(agent_event_dispatcher.py:57-68),沒問題。換 at 確實能繞過去重重送,但各 handler 有自己的冪等:日誌以 (agent_uid, agent_log_id) 唯一、主控台稽核以已寫入的 at 去重、升級事件以 at 新舊判斷、狀態只寫有變動的值,所以重送不會造成重複資料。本棒範圍內沒看到每台 Agent 的頻率上限(可能在通行證守門或前門,不在 scope)。長度沒上限的部分就是第 2 條。
2 _agent_status.py 白名單鍵寫回 remote_agents,有沒有能改狀態、租戶、憑證、enabled 的鍵;browser_port 能不能把任意網址塞進 CSP 不成立。:46-49 先濾成 AGENT_STATUS_KEYS 白名單內的鍵,再交給套件各自的值域檢查;browser_port 只收 1~65535 的整數(:37-41)。接縫 core/plugins/_agent_origins.py:28-35 用的主機名取自 base_url、埠是整數,組好後再用 _SAFE_ORIGIN 正規式驗過才寫進 CSP,塞不進任意網址。白名單內各鍵的實際值域在 jedi_remote_agent 套件,不在 scope,沒逐鍵深追。
3 _agent_logs.py+agent_log_domain_service.py:訊息長度、筆數、等級白名單;文字之後顯示在日誌查詢頁 部分成立。筆數上限 500(:52)、等級只收白名單(:56)、其他欄位有截長度(:65)。訊息長度有一個洞,就是第 1 條。顯示到日誌查詢頁時是否跳脫屬前端,不在 scope。
4 _finish 用 Agent 自報版本與成敗改更新中心結果:偽造「升級成功」會不會讓更新中心停止重試 成立但屬設計前提。agent_update_event_handlers.py:188-200 確實直接用 Agent 自報的版本與成敗呼叫 record_result;被控的 Agent 謊報成功,更新中心會把它自己那一台記成升完。但它只能謊報自己,不能替別台 Agent 報;是否會讓整批「已全部升完」取決於 record_result 與 tick(在 jedi_remote_agent/更新中心,不在 scope)。主系統沒有別的管道得知 Agent 真實版本以外的心跳版號,這是信任模型的限制,不算本棒新增的洞,工具也沒報。建議首腦評估是否要用下一次心跳回報的版本二次確認。
5 save_log 附件大小上限;檔名由系統產;clean_job_id 白名單 不成立。:224 先擋 10MB 上限、:226 檢查 gzip 檔頭、:223 用 ^[A-Za-z0-9._:-]{1,64}$ 白名單檢查工單編號,檔名由系統組(:243),同一工單重傳回第一次那份(:240)。注意:大小檢查發生在整包已讀進記憶體之後,讀取上限在 route(不在 scope)。
6 _agent_console_audit.py 把 Agent 自報的 actor 寫進稽核:能不能以真實帳號名義寫稽核 成立,就是第 3 條。
7 _agent_error_tracking.py 註冊回應附帶錯誤追蹤設定:送給 Agent 的是不是只有它該拿的那一份 不成立。:16-26 只回 enabled、environment 與 Agent 專用的 SENTRY_AGENT_DSN,不帶後端自己的 SENTRY_DSN;:46 只在註冊端點且 200 時才補。所有 Agent 拿到同一份 Agent 專用連線設定(不分租戶),這是錯誤追蹤的連線位址,本身不是可以讀資料的密碼。

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

  • 「這些存在嗎」:可信。三條都經過三個獨立檢查員(分別看「打得到嗎」「後果多大」「有沒有別的防線擋住」)投票,三條都是 3 票全數認定成立,首腦也自己開檔核對過行號與程式。
  • 「只有這些嗎」:不能保證。這是 effort low 的掃描,只有一個研究員讀完整個範圍(另一個專掃寫死在程式裡的密碼,沒找到)。範圍外的套件(jedi_remote_agent、jedi_agent_protocol)與前門、通行證守門只追到接縫,沒深掃;掃描是讀程式碼推論,沒有實際執行任何攻擊。

6. 執行概況(照 stamp 抄)

項目 值
run ID wf_80d1e3fa-d85
掃描版本 31345fbe0c9c(工作目錄乾淨)
設定 scoped,10 檔,effort low,focus attack-surface
verification.status verified
reason_kind 無(null)
candidates 3(去重後 3)
panel_votes 9(三個檢查員對 3 條各投一票,9 票全數投出,沒有漏投)
researchers_dispatched/returned 2/2
耗時 約 33 分鐘
原始報告歸檔 docs/features/security-scan-consolidated/runs/be/CLAUDE-SECURITY-20261108-171721/