⚠️ 歷史紀錄:本報告描述的問題已於 2026-11-07 全部修正並合回 Agent 主線(修正卡 CM-3559~3564、3573~3576、3584,驗收紀錄見母卡 CM-3507)。文中的檔名與行號是掃描當下(commit
bab2aa3)的版本,修正後已變動,請勿拿來定位現行程式。
有一條要優先處理。 找到 2 個問題。最嚴重的是 F1(高):只有「發動本機掃描」權限的主控台使用者,可以指定任意 CINC/InSpec 檢測規則包(profile)的路徑或網址,讓 Agent 執行他自己寫的 Ruby 程式碼,也就是拿下整台 Agent 主機。另一條是 F2(中):SonarQube 的 repo_url 欄位沒有經過「授權目標清冊」(只准連哪些主機的名單)的檢查,可以拿來叫 Agent 去連內網任意主機。兩條都經三個獨立檢查員投票 3 比 0 成立,我也自己開檔核對過。
注意:兩條的「出事點」都在範圍外的 connector 檔(inspec.py、sonarqube.py),但問題出在範圍內的 local_runs.start:它把使用者填的 params 原樣交出去,只檢查目標欄位。工具是順著這條資料流追出去的,所以照常報。
local_runs 表);probe_runner.py,就是設定工具時按「測試連線」跑的那段);範圍共 13 檔、約 1074 行。為了查證呼叫端,有翻範圍外的 console/api/scan_routes.py、sync/app/allowed_targets.py、files/api/browser_plane.py 與兩支 connector;這些檔只為查證而讀,本身不當作發現。
| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | 手動掃描時,使用者可以自己填 InSpec 規則包的路徑或網址,Agent 原樣拿去執行 | 規則包裡的 Ruby 程式碼在 Agent 主機上執行,可以讀走本機存的所有工具帳密、SSH 金鑰與資料庫 | ①主控台帳號有 run-local-scan 權限 ②Agent 設好了 inspec/gcb 帳密 ③清冊裡至少有一台授權目標 |
guidant_agent/scan/adapters/connectors/inspec.py:913(入口 console/api/scan_routes.py:159、scan/app/local_runs.py:114-122) |
手動掃描只收「主系統同步下來的規則包代號」,由伺服器端解析;手填的路徑和網址一律拒收 | 高 |
| F2 | SonarQube 的 repo_url 沒過授權目標清冊,Agent 會對任意主機做 git clone |
Agent 被當跳板去連內網任意主機與埠,錯誤訊息回給使用者,可以拿來探測內網 | ①有 run-local-scan 權限 ②設好了 SonarQube ③Agent 主機裝有 git |
guidant_agent/scan/adapters/connectors/sonarqube.py:838(檢查只看 sync/app/allowed_targets.py:37-40 列的欄位) |
repo_url 加進清冊檢查,或另設允許的 Git 主機清單;關掉 redirect;錯誤訊息不回傳 git 原文 |
中 |
為什麼 F1 是高不是嚴重: 要先有一個能登入主控台、而且帶掃描權限的帳號(一道真的關卡)。但過了這道關,就沒有任何防線了:一般的「掃描操作員」可以直接拿到整台主機與上面所有帳密,等於權限大躍升。
白話: InSpec(CINC Auditor)的「規則包」本身就是 Ruby 程式碼,執行規則包就是在 Agent 上跑程式。正常路徑是用主系統同步下來的規則包(參數名 _profile),會經過 archive_safety(安全解壓)和範本檢查。但 connector 還保留一條舊的「手填路徑」:參數名 profile,填什麼就跑什麼。主控台的 _build_params 只拿掉 _ 開頭的欄位,所以使用者送 profile 可以一路傳到 cinc-auditor exec <profile>。
出事會怎樣: 攻擊者先用本機上傳槽傳一個惡意規則包(檔案會落在 <資料目錄>/scan-input/<scan_uid>/),或者乾脆放在自己的網站上,再發一次掃描把 profile 指過去。Agent 以服務帳號身分執行裡面的 Ruby,可以讀 credential_store(所有工具的帳密、SSH 私鑰)、註冊金鑰、SQLite 資料庫。填網址的話,Agent 還會去任意主機下載,這也繞過了授權目標清冊。
要先有什麼才打得到: 主控台帳號有 run-local-scan 權限(不需要 configure 設定權限)、Agent 設好了 inspec 或 gcb 的帳密、清冊至少有一台授權目標。
在哪裡:
guidant_agent/console/api/scan_routes.py:159:只濾掉 _ 開頭的欄位(範圍外,只為查證);guidant_agent/scan/app/local_runs.py:114:check(params) 只檢查目標欄位;:122 把 params 原樣交給 _run_one;guidant_agent/scan/adapters/connectors/inspec.py:875-878:_resolve_profile 的手填分支,原樣回傳;guidant_agent/scan/adapters/connectors/inspec.py:913:cinc-auditor exec 拿到這個值。怎麼修: 主控台發起的手動掃描(source=manual)不接受 profile,只接受同步下來的規則包代號,由伺服器端透過 profile_sync/profile_cache 解析。手填的網址、快取目錄以外的絕對路徑、- 開頭的值一律拒收。可以在 _build_params 或 local_runs.start 擋。
runner 開檔核對: 成立。 scan_routes.py:159 的濾法、inspec.py:874-878 的手填分支(raw_profile is None 就直接 return profile),還有 :913 的指令組裝,我都開過,跟描述一致。allowed_targets.py:37-40 的檢查欄位清單裡也確實沒有 profile。
repo_url 繞過授權目標清冊白話: 授權目標清冊只檢查固定幾個欄位名(hosts、targets、host、ips、target_url、urls、login_url)。SonarQube 的 repo_url 不在裡面,_resolve_repo_url 也只檢查網址是不是 http/https 開頭,Agent 就會照著 git clone 任何網址。
出事會怎樣: 使用者填 http://10.0.5.7:8080/ 這類內網位址,Agent 會從客戶內網送出 GET 請求。看執行紀錄的錯誤訊息(連線被拒、逾時、404,git 錯誤原文最多 500 字)就能分辨內網哪些主機、哪些埠是開的,等於把 Agent 當成內網探測器,打破「Agent 只連授權目標」的保證。影響有上限:只能送 GET,回來的內容也只有片段。
要先有什麼才打得到: 有 run-local-scan 權限、Agent 設好了 SonarQube、Agent 主機裝有 git。
在哪裡:
guidant_agent/scan/adapters/connectors/sonarqube.py:429-443:_resolve_repo_url 只檢查 scheme;guidant_agent/scan/adapters/connectors/sonarqube.py:838:git clone ... repo_url;guidant_agent/sync/app/allowed_targets.py:37-40:檢查的欄位清單(範圍外,只為查證)。怎麼修: 把 repo_url 加進 URL_PARAM_KEYS,跟其他網址一樣解析成 IP 後比對清冊;或者另外設一份允許的 Git 主機清單。git 加 -c http.followRedirects=false;錯誤訊息只回白話,不回 git stderr 原文。
runner 開檔核對: 成立。 三個位置我都開過。
本機上傳票(scan_input.py:81-111)
secrets.token_urlsafe(32),記憶體只存 sha256 雜湊;有效期 LOCAL_SLOT_TTL_SEC = 600(10 分鐘)。max_bytes:upload 100 MB、sbom 50 MB;同時最多 32 張未用的票。consume_local_token 驗過就刪(del s.pending[key]),所以只能用一次;有綁 scan_uid(:108),對不上就拒絕,而且不燒票。files/api/browser_plane.py:111。正常部署是單行程。local_upload(scan_uid, sub) 有驗 slot.sub != sub,別人傳的檔不給用。路徑拼接(scan_input_files.py、scan_input_slots.py)
find_local_input:scan_uid 先過 SAFE_DIR_NAME(只允許單層安全名稱,擋 .. 和 /);file_name 用 os.path.basename 取最後一段,排除 symlink。browser_plane.py,有 _safe_file_name 處理。purge_expired_inputs 不是定時排程,是 sonarqube/sbom 每次掃描時順手清,保留天數照 scan_input_retention_days() 設定。uuid4,資料夾不會共用。local_runs.py 的 params、summary、列表
public_params(domain/local_run.py:14-17),會拿掉名稱含 pass|secret|token|api_key|private_key|credential|username 的欄位,帳密不會進 local_runs.params 明文欄位;完整 params 只在記憶體交給 connector。finish 的 summary 是 connector 回的結果摘要加錯誤訊息,沒有另外過遮罩。connector 的慣例是不把帳密拼進錯誤訊息(probe_runner.py 也註明了),沒有發現實際外洩點。started_by 過濾,而是依權限範圍(scope)過濾(console/api/runs_routes.py:184-186,範圍外)。也就是有對應權限的人看得到所有人的執行紀錄。這是單租戶 Agent 的設計:同一台 Agent 的操作員共享紀錄,不算越權。另外,報告檔要另外經 send_protected 下載。local_runs_repo.py 的 SQL
:uid 形式)。:77-85,只用來拼 WHERE 子句的結構::k{i} 這類佔位名稱,i 是迴圈序號,值本身仍然走參數,沒有注入點。json.loads 在 local_runs.py:322-330 的 _decoded 有包 ValueError,壞掉的 JSON 讀回成 None。probe_runner.py
:51 用 sync_ports.targets().check(params) 檢查目標,然後呼叫 connector 的 probe();本身沒有 subprocess,外部指令都在 connector 裡。模組註冊與授權檢查三條路
local_runs.py:114 有 check(params)。task_executor.py:74 有 check(範圍外,工具也確認過)。probe_runner.py:51 有 check。check(),但 check() 只看固定的欄位名,所以 repo_url(F2)、profile(F1)這種「間接指定連線對象」的欄位都檢查不到。services() 單例持有上傳槽(pending、uploaded,有上限)與執行佇列,沒有存帳密。migrations.py
source、started_by_name、progress 三欄,沒有帳密欄位。| 項目 | 值 |
|---|---|
| run ID | wf_c003862e-f78 |
| 掃描版本 | 749d3b42529fe63dc2b07d866e7fe9bed3e16951 |
| 強度與範圍 | low,13 檔 |
| verification.status | verified(reason_kind 為 null) |
| candidates(候選問題) | 2(去重後 2) |
| panel_votes(檢查員投票) | 6:三個檢查員對 2 條各投一票,全部投出 |
| researchers_dispatched / returned(派出/回來的研究員) | 1 / 1 |
| 耗時 | 約 28 分鐘,7 個 agent,約 47.5 萬 token |
| 原始產物 | docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261105-171214/ |