# FR-177 C7 掃描報告：網路診斷探測十一檔

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

## 🔴 一句話結論

**沒有要緊急處理的。** 只找到 1 個問題，嚴重度「低」：DNS 探測收回覆的迴圈沒有總時限，有人一直丟垃圾封包過來，這次診斷就永遠結束不了，之後的網路診斷全卡在排隊，要重啟 Agent 才恢復。三個獨立檢查員投票 3 比 0 成立，我也自己開檔核對過。卡片列的「重點看什麼」6 條逐條人工查過，都不成立（詳見後面的查證表）。最受關注的「目標字串以 `-` 開頭、被 ping 當成選項」這條打不到：使用者填的目標在進探測之前，一律先過 `ipaddress.ip_address()`，只有合法 IP 才會往下走。

## 這一棒在檢查什麼

主控台「網路診斷」的十項固定探測：ping、traceroute、mtr（持續路徑監測）、MTU、DNS、TCP 埠、TLS 憑證、HTTP、SSH、到主系統（cloud）連線序列，加上共用的 `__init__.py`（結果格式、目標 `Target`、執行情境 `ProbeContext`）。這裡是 Agent 上唯一准許執行外部指令（`ping`／`traceroute`）的地方，指令全部經 `shared/timeouts.run_cmd` 執行：只收字串陣列、`shell=False`（不經過 shell 解析）、固定逾時、輸出上限 64KB。

範圍：`guidant_agent/netdiag/adapters/probes`，11 檔、897 行。為了查證目標從哪裡來，翻了範圍外的 `netdiag/app/runner.py`（組 `Target` 的地方）、`netdiag/app/dashboard.py`（讀閘道與 DNS 伺服器）、`netdiag/domain/limits.py`（埠數上限）、`shared/timeouts.py`；這些檔只為查證而讀，不當作發現。

## 掃到什麼：總覽表

| # | 這是什麼問題 | 出事會怎樣 | 要先有什麼才打得到 | 在哪裡 | 怎麼修 | 嚴重度 |
|---|---|---|---|---|---|---|
| F1 | DNS 探測等回覆時，收到對不上的封包就丟掉再等，沒有總時限，也不檢查封包是誰送的 | 這次診斷一直不結束，佔住「同時只跑一個診斷」的鎖和排隊位置；之後所有網路診斷（含未同步時唯一能跑的主系統連線診斷）都卡住，直到 Agent 重啟 | ①有網診權限的主控台使用者對「本機 DNS 伺服器」跑 DNS 探測 ②攻擊者就是那台 DNS 伺服器，或在同網段能把 UDP 封包打到 Agent 的臨時埠 | `guidant_agent/netdiag/adapters/probes/dns.py:53`（`_query_server`） | 迴圈開始前先算好截止時間，每圈只等剩下的時間，到點就丟逾時；socket 先 `connect((server_ip, 53))`，或丟掉來源不是 `server_ip` 的封包；限制最多收幾個封包 | 低 |

**為什麼是低：** 影響只有「網診功能停擺」，不會洩漏資料、不能執行程式，重啟就好；而且攻擊者要嘛就是那台 DNS 伺服器，要嘛得在網路上看得到、打得到這個查詢。

## 每條發現的詳述

### F1 — DNS 探測接收迴圈沒有總時限

**白話：** DNS 探測對本機設定的 DNS 伺服器送一筆查詢，然後在 `while True` 迴圈裡等回覆。每次 `recvfrom` 等 N 秒，收到的封包交易號（txid，查詢時隨機產生的 16 位元編號）對不上就丟掉、繼續等。問題有兩個：一是每收到一個垃圾封包，等待的計時就重新開始，沒有「整個探測最多等多久」的上限（不看 `ctx.deadline`，也不數圈數）；二是 socket 沒有 `connect`，誰送來的封包都收，也不檢查來源位址。

**出事會怎樣：** 只要每隔幾秒（短於單次逾時）丟一個 txid 錯的 UDP 封包過來，`_query_server` 就永遠不會回傳。網路診斷同時只能跑一個、排隊只有一格，所以之後的每一次網路診斷都卡住，直到 Agent 重啟。另外，如果偽造的封包剛好猜中 txid（1/65536），報告上會出現假的 DNS 結果。

**要先有什麼才打得到：** 有網診權限的主控台使用者對 `dns` 類目標跑 DNS 探測；攻擊者是那台 DNS 伺服器（惡意或被入侵），或者在路徑上、同網段，能把 UDP 封包噴到 Agent 的臨時來源埠。

**在哪裡：** `guidant_agent/netdiag/adapters/probes/dns.py:50-57`。`:51` 只設一次單次逾時，`:53` 是 `while True`，`:54` 的 `recvfrom` 把來源位址收進 `_addr`，但從來沒有使用。

**怎麼修：** 迴圈前先算 `end = time.monotonic() + timeout`，每圈設 `sock.settimeout(剩餘時間)`，時間到就丟 `TimeoutError`；socket `connect` 到 `(server_ip, 53)`，讓作業系統直接擋掉別人送來的封包；再加一個最多收幾個封包的上限。

**runner 開檔核對：** **成立。** `dns.py:50-57` 的 socket 沒有 `connect`、`_addr` 沒有使用、迴圈沒有任何出口條件，除了收到對得上的回覆，或單次 `recvfrom` 逾時拋出例外。只要封包持續進來，就不會逾時。嚴重度同意「低」。

## 重點逐條人工查證

| # | 首腦標的重點 | 人工查過 | 為什麼 |
|---|---|---|---|
| 1 | `ping.py:27`／`traceroute.py:28`：`target.ip` 以 `-` 開頭會被當成選項 | **不成立** | `Target` 只在 `netdiag/app/runner.py` 組。使用者填的目標走 `_address_target`（`runner.py:117`），先 `ipaddress.ip_address()` 再過授權清冊，所以進 argv 的一定是合法 IP 字串，不可能以 `-` 開頭。主系統目標也一樣，先 `ip_address()`，不是 IP 才用 `getaddrinfo` 解出位址（`:89-101`）；閘道從 `/proc/net/route` 解成 `IPv4Address`，或取 `ip -j route` 的輸出。小瑕疵：DNS 伺服器位址取自 `/etc/resolv.conf` 原文，沒過 `ip_address()`，但要改那個檔得先有 root，不算可利用。建議當加固項：argv 在目標前加 `--`，或在 `Target` 建構時統一驗 IP。 |
| 2 | `mtr.py` 上限、`mtu.py` 的 `size` 來源 | **不成立** | mtr 總探測數上限是 `limits.CONNECTIONS["mtr"]`，總時間上限是 `limits.TIMEOUT_SEC["mtr"]`，先到先停，每個探測單次逾時 3 秒；`size` 是寫死的常數（1472／1452），沒有外部輸入。 |
| 3 | `cloud.py`：`raw=str(e)[:300]` 帶出什麼、TLS 有沒有真的驗證 | **不成立** | 目標由 Agent 設定的 `cloud_endpoint` 算出，不收外部輸入；`str(e)` 是作業系統或 SSL 的錯誤字串，最多 300 字，不含 DNS 回應原文。TLS 用 `ssl.create_default_context()`（或主系統信任錨 `cafile`），會驗證憑證鏈和主機名稱；驗證不過只回原因。`tls.py` 同樣用預設驗證 context，憑證主體只在驗證通過後才回報。 |
| 4 | `http.py` 跟不跟重導向、body 存多少；`ssh.py` 會不會登入；`port.py` 上限 | **不成立** | `http.py`：`follow_redirects=False`，自己追最多 5 跳，換 host 就停；只讀標頭、不讀 body，標頭總長超過 16KB 就停，只保留 6 個指定標頭，每個最多 200 字。而且目標本身已經過授權清冊，不構成新的 SSRF（伺服器端請求偽造）跳板。`ssh.py`：只讀最多 255 bytes 的版本 banner，不送任何資料、不嘗試認證。`port.py`：埠數上限 5 個，在 `limits.normalize` 擋一次、`port.run` 再擋一次；不提供埠範圍，每個埠之間經 pacer 間隔。 |
| 5 | `__init__.py`：`ctx.timeout()` 來源、有沒有探測繞過 `run_cmd` | **不成立** | `timeout()` 取固定逾時和 `deadline` 剩餘時間的較小值（最少 0.5 秒）。`grep -rn subprocess probes/` 只命中 `__init__.py:4` 的註解，沒有任何探測直接呼叫 `subprocess`。但也因此發現 F1：純 socket 的探測如果自己寫迴圈，`ctx.timeout()` 管不到。 |
| 6 | 原始輸出進報告，有沒有帶出 Agent 自己的環境資訊 | **不成立** | 進 `raw` 的是 ping／traceroute 的輸出（統計行、每一跳的 IP，這是刻意要給的），以及 socket 或 SSL 的錯誤字串，都不含容器 hostname 或檔案路徑。代理資訊經 `cloud_env.proxy_info()`，`dashboard._strip_userinfo` 會去掉網址裡夾的帳密。 |

## 可信度兩層

- **工具層：** 1 位研究員讀完整個範圍，提出 1 個候選；三視角檢查員（打不打得到／影響多大／有沒有防線）投票 3:0 成立。蓋章狀態 `verified`。
- **人工層：** F1 開檔核對成立；6 條重點逐條人工查過，都不成立，另外記了一項加固建議（argv 加 `--`）。所有結論都來自讀程式碼，沒有實際執行或打 exploit。

## 執行概況（照 stamp 抄）

- run ID：`wf_a08f8798-b45`；revision `6d1e6ca`（working tree 乾淨）；effort `low`；scope `guidant_agent/netdiag/adapters/probes`（11 檔）
- `verification`：status `verified`；candidates 1；candidates_deduped 1；panel_votes 3；panel_reviewed_findings 1；panel_quorum_findings 1；unreviewed_candidate_sites 0；incomplete_panel_candidates 0；reason null；researchers_dispatched 1／returned 1
- 面板完整跑完，共 1 輪；沒有掉研究員，也沒有遺失候選。
- 歸檔：`docs/security-scan/FR-177/runs/CLAUDE-SECURITY-20261106-035701/`
