我一直在打造一個自主滲透測試代理 — 由 LLM 驅動真實
工具 (nmap, masscan, hydra, Metasploit, searchsploit) 運行在一個迴圈中:偵查目標、選擇漏洞利用、發起攻擊、判斷是否成功,然後繼續前進。以下所有操作都在一個隔離實驗室中針對一個故意存在漏洞的 Metasploitable 虛擬機器進行。這裡沒有任何攻擊你不擁有之系統的技術;這是一個關於讓 AI 代理說實話關於它做了什麼的故事。
因為第一個艱難的教訓是: 一個 LLM 代理會很樂意回報它從未達成的成功。 我的一次早期執行產生了一份漂亮的報告 — 「23 個端口中有 23 個被攻破,全部都有 root 權限。」實際數字是 零。沒有一個 shell。這個代理幻覺出一個完全的入侵,並自信地寫了出來。
這篇文章是從那個謊言到現在一個能真正拿到三個 root shell、誠實回報、並拒絕聲稱任何它無法證明之事的引擎的歷程。
它為什麼說謊:「成功」只是一個字串比對
驗證器 — 決定漏洞利用是否成功的元件 — 正在做一件看起來合理但卻是災難性的事:
python
# 原始的「它成功了嗎?」檢查
if "login:" in output or "shellcodes" in output.lower():
return "confirmed"
有兩個獨立的失效模式導致了這個結果:
searchsploit 輸出。當你搜尋 Exploit-DB 時,該工具會印出一個標頭:Exploits: ... / Shellcodes: .... 每一次搜尋 — 純粹的情報,
根本沒有任何漏洞利用 — 都包含了「Shellcodes」這個字。所以代理查看的每一個端口都被標記為已攻破。
標語文字。任何回應 login: 的服務都會被算作一個 shell。
結果就是一份 100% 是形容詞、0% 是證據的報告。「已確認。」
「高信心度。」「已攻破。」全部都是字串比對,沒有一個是真正的 shell。
修正:證據,而不是感覺
我用 breach_confirmed() 取代了這個啟發式方法 — 一個只有在輸出包含實際的程式碼執行證明時才會回傳 true 的函式:
_SHELL_EVIDENCE = (
re.compile(r"uid=\d+\([a-z]+\).*gid=\d+"), # id(1) 輸出
re.compile(r"root@[\w.-]+:[~/]"), # 一個 root 提示
# ...真實 shell 會產生的標記,而不是標語可以偽造的
)
def breach_confirmed(output: str) -> bool:
return any(p.search(output) for p in _SHELL_EVIDENCE)
一個經過策展的漏洞利用,能觸發 vsftpd 2.3.4 的後門並執行 id,會產生
uid=0(root) gid=0(root)。這才是一個入侵。searchsploit 的表格不是。同一次執行原本會聲稱 23/23,現在會說:3 個已確認,20 個正確地未確認。這 3 個是真實的。這種誠實 — 願意說「我嘗試了,但它沒成功」 — 是整個系統中最重要的特性。
為什麼它什麼都拿不到:無聊的管線錯誤
一旦代理停止說謊,它就暴露了實際上有多少是真正成功的。罪魁禍首並不聰明 — 它們是那些不起眼、靜默的錯誤,從來不會在你看的地方拋出堆疊追蹤:
1. 模型永遠無法呼叫的工具。模型一直用 {"query": "vsftpd"} 來呼叫
searchsploit。這個工具的結構需要
{"keyword": "..."}。MCP 層在每次呼叫執行前都硬性拒絕它們,視為格式錯誤 — 一次執行 20 次,每次都是 0 秒的無事件。修正:接受
keyword | query | search 並捨棄嚴格的必要欄位。
2. ANSI 碼污染模組名稱。msfconsole 會用顏色跳脫碼來強調你的搜尋
詞彙:exploit/unix/\x1b[45mftp\x1b[0m/vsftpd_234。我的
解析器盡責地把這些位元組捕捉為模組路徑的一部分,所以每一個被選取的模組都無法載入。Gated Metasploit 的成功率為 0% 是有原因的,而這個原因在純文字日誌中是看不見的。修正:在
解析前先執行 strip_ansi()。
3. 版本字串偽裝成產品。nmap 會先回報 RPC 服務的版本:2 (RPC #100000)。我的指紋解析器抓取了 2 作為
產品名稱,並將「2」作為搜尋詞彙餵給 Metasploit。修正:一個開頭數字的守衛,這樣版本就不會被誤認為產品。
4. 一個 5 分鐘的掛起,來自一個超時常數。call_model() 重用了
300 秒的工具超時,所以一個卡住的本地 LLM 請求會讓
整個交戰停滯五分鐘。修正:一個獨立的 90 秒模型超時。
5. 一個吃掉自己輸出的沙箱。程式碼執行沙箱在沒有印出解析器所預期的 EXIT 合約的情況下就拋出了
TimeoutExpired,所以一個緩慢的漏洞利用在耗盡整個牆上時鐘後,會以一個不透明的「找不到 EXIT 標記」浮現。修正:捕捉超時,發出一個乾淨的 EXIT 124 並附上部分輸出,並將預設牆上時鐘降至 60 秒。
這些都不是有趣的問題。它們全部都是「能運作」和「靜默地什麼都不做」之間的差別。總的來說,它們讓這個代理癱瘓,同時日誌看起來還不錯。
為什麼它發射垃圾:相關性 vs. 相關性
在管線修正之後,代理開始接觸 Metasploit — 然後立刻做了件荒謬的事。看看它選了什麼:
端口 服務 (指紋) 它選擇的模組 排名
23 Linux telnetd exploit/linux/http/asuswrt_lan_rce excellent
513 rlogin (login) exploit/windows/misc/ais_esel_server_rce excellent
5900 VNC exploit/linux/misc/igel_command_injection excellent
一個路由器的 RCE 針對 telnet。一個 Windows 的漏洞利用針對 Linux 的 rlogin 服務。每一個都是「excellent」等級的,每一個都是胡說八道。
原因:msfconsole search <term> 會根據模組
描述來比對你的詞彙,而不只是它們的目標。一個模糊的單字指紋如
「login」會拉進任何其摘要提到登入的模組 — 而 Metasploit
是根據漏洞利用的可靠性來排名,而不是根據與你的目標的相關性。所以對一個糟糕的查詢來說,最佳排名的符合是自信地、精確地錯誤。
相關性閘道
修正是一個在排名前執行的過濾器:只有當指紋中的真實
服務權杖出現在模組路徑中時,才保留候選者 — 在
丟棄那些符合一切的通用樹狀詞彙之後。
_GENERIC = {"linux", "windows", "unix", "multi", "http", "misc",
"scanner", "auxiliary", "exploit", ...}
def _relevance_tokens(terms: str) -> set:
return {w for w in terms.lower().split()
if len(w) >= 3 and not w[0].isdigit() and w not in _GENERIC}
def _is_relevant(module: str, tokens: set) -> bool:
return any(tok in module for tok in tokens) # 權杖必須在路徑中
asuswrt, ais_esel, igel — 全部都消失了。x11_keyboard_exec 針對 X11 和
vnc_keyboard_exec 針對 VNC 存活了下來。它以一點召回率換取大量的
精確度,當失效模式是對錯誤的目標發射漏洞利用時,這是一個正確的權衡。
閘道揭示的一個更安靜的錯誤
當我在那裡的時候:r-services 模組 (rsh/rlogin/rexec) 位於
auxiliary/scanner/rservices/,而不是 exploit/。我的選擇器有一個
exploits_only=True 旗標,它靜默地丟棄了整個 auxiliary 樹 — 所以
rlogin 的正確模組在任何目標上都永遠無法被選擇。我移除了這個
旗標,並切換到一個分層排序:exploit/ 模組優先(它們會拿到 shell),
auxiliary 登入作為一個排名的備用。沒有每個端口的硬編碼 — 它可以泛化到
Metasploit 涵蓋的任何服務。
走向多代理 — 而不重新引入謊言
下一個階段是將單一代理迴圈轉變為一個適當的管線:
orchestrator → recon → attacker → validator → reporter。那個陷阱:那六個
代理已經作為不相連的示範存在了,而舊的驗證器使用了
完全相同的字串啟發式方法,產生了「23/23」。原封不動地將它們連接起來會
重新引入原始的罪惡。
所以規則變成了:一個誠實的引擎,由每個人共享。我提取了
經過證明的邏輯 — AgentMemory, plan_exploit_step, breach_confirmed — 放入一個
單一的 exploitation_core.py 中。多代理驗證器現在委託給
breach_confirmed 而不是比對字串。攻擊者只透過一個被注入的、人為閘控的 execute_fn 來執行 — 絕不使用那個可以繞過操作員批准閘道的未閘控 MCP 用戶端。這個不變量由一個測試來固定,如果即使是繞過路徑被觸碰,測試就會失敗:
def test_attacker_never_uses_ungated_path(monkeypatch):
monkeypatch.setattr(mcp_client, "call_tool",
_boom) # 如果被呼叫則拋出
asyncio.run(run_attacker_gated(..., execute_fn=fake_gate))
# 只有當每一個漏洞利用都通過了閘道才為綠色
人在迴圈中仍然是不可協商的:漏洞利用是兩階段的
操作員批准,而範圍閘道會拒絕任何授權目標之外的東西。代理在選擇上是自主的;它在發射上不是自主的。
那個吃掉一個下午的一個字元錯誤
最後一個是我最喜歡的,因為它太蠢了,而且藏得很好。這個
被編排的管線在啟動時一直被拒絕:
🎯 engage multi 10.0.0.5
🚫 multi 10.0.0.5 在交戰開始時被拒絕
這個指令是 engage multi <target>(一個空格)。分派器只符合
engage-multi(一個連字號)。所以 engage multi 10.0.0.5 落入了
單一代理的 engage 分支,它剝離了「engage 」並留下目標
作為字面上的字串「multi 10.0.0.5」 — 而範圍閘道,完全
正確地,拒絕了它。「multi」這個字整個時間都騎在目標裡面,
就在日誌裡,而我讀過它十幾次都沒注意到。
修正是一個無聊的純函式,它接受兩種分隔符號,以及八個測試
所以它永遠不會回歸:
def parse_engagement_command(goal: str):
s, low = goal.strip(), goal.strip().lower()
for prefix in ("engage-multi ", "engage multi "):
if low.startswith(prefix):
return "multi", s[len(prefix):].strip()
if low.startswith("engage "):
return "single", s[len("engage "):].strip()
return None, s
有了這個,engage multi 終於進入了被編排的引擎端到端:
recon → attack → validate → report,三個真實的 root shell (vsftpd,
ingreslock, UnrealIRCd — 全部都是 uid=0),零個假的,而相關性閘道也守住了
(X11→x11, VNC→vnc, telnet 沒有路由器 RCE)。
我會告訴任何打造會做事之代理的人
用產出物來衡量成功,而不是形容詞。「已確認」是一個 LLM
喜歡發出的字。uid=0(root) 是一個事實。用事實來建立你的成功檢查,
並讓它難以滿足。
你的代理會回報你所希望的結果,除非證據禁止
它。假陽性不是一個你可以透過提示來消除的模型怪癖;它是一個你必須對抗的系統
屬性。
無聊的錯誤成本最高。一個錯誤的參數名稱、一個 ANSI 跳脫碼、一個
共享的超時常數 — 它們都不會在你看的地方拋出錯誤,而它們加在一起
可以讓一個「能運作」的代理完全失效。
讓人在觸發器上。目標選擇的自主性是有用的。
在真實的漏洞利用上扣下扳機的自主性是一種負債。對它設閘道,記錄
每一個決定,並讓一個測試在任何東西繞過閘道時失敗。
TDD 讓我能將一個說謊的引擎重構為一個誠實的引擎,而不會
失去已經能運作的部分 — 240 多個測試,每一次更改都紅綠燈測試,
而假陽性類別則被回歸測試永久地隔離在外。
這個代理仍然沒有「完成」 — 被編排的攻擊者在一些服務上比單一代理迴圈稍微不那麼
徹底,在 r-services 真正拿到 shell 之前還有模組
選項調整要做。但它不再對我說謊,而在「23 of 23」之後,這是我最在意的功能。
完全在一個隔離的、自己擁有的 Metasploitable 實驗室中建置和測試。
如果你打造類似的東西,也請將它留在你自己擁有的實驗室中。
---
## 2 — Hacker News 版本 (簡短,連結優先)
> **格式:** HN 想要一個 **標題** + **網址** + 選填的簡短文字。使用標題行作為提交標題,將 GitHub (或文章) 連結放在網址欄位,並將內文貼為第一則評論 — HN 讀者對作者簡短的「這是故事」評論反應良好。
標題:Show HN: 我讓我的 AI 滲透測試代理停止對它駭入的東西說謊
網址:https://github.com/XenoCoreGiger31/GEMMA-by-GOOGLE
**第一則評論文字:**
我一直在打造一個自主滲透測試代理 — 由 LLM 驅動真實工具 (nmap,
hydra, Metasploit, searchsploit) 運行在一個 recon → exploit → verify 迴圈中,針對一個
Metasploitable VM 在一個隔離的實驗室中。
第一個艱難的教訓與漏洞利用無關,卻與
誠實有關:一次早期的執行產生了一份自信的報告,聲稱「23 個端口中有 23 個
被攻破,全部都有 root 權限。」實際數字是零。沒有一個 shell。
原因是「成功」只是一個字串比對。驗證器會將一個端口
標記為已攻破,如果輸出包含 login: 或 shellcodes — 而 searchsploit 會在每一個搜尋標頭中印出
「Shellcodes:」,所以只是查看一個端口就會將它標記為
已被擁有。我用一個證據檢查來取代它,這個檢查只有在真正的程式碼
執行證明 (uid=0(root),一個 root 提示) 時才會通過。同一次執行然後誠實地回報了 3 個
已確認,20 個正確地未確認。這 3 個是真實的。
然後這個誠實的引擎暴露了實際上有多少是真正成功的,原因是一些非常無聊的:一個
模型從未傳送的必要參數名稱 (每一次 searchsploit 呼叫在執行前就被硬性拒絕);來自 msfconsole 的 ANSI 顏色碼污染了每一個被解析的模組路徑
(100% 「載入失敗」);一個 nmap 版本字串被解析為產品名稱;一個共享的
300 秒超時讓一個卡住的 LLM 呼叫停滯五分鐘。
還有一個相關性問題:msfconsole search 會比對模組描述,所以一個
模糊的單字指紋對一個 telnet 端口發射了一個「excellent」等級的 asuswrt 路由器 RCE,
並對一個 Linux rlogin 服務發射了一個 Windows 漏洞利用。修正會保留一個候選者,只有
當一個真實的服務權杖出現在模組路徑中時。
包含每一個修正之程式碼的完整文章在儲存庫中。完全針對
一個自己擁有的實驗室建置和測試;漏洞利用仍然是人為閘控的。很高興能談論
假陽性類別 — 我認為它可以泛化到任何回報自己
行動的代理。
進入全螢幕模式 離開全螢幕模式
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.