My AI pentest agent reported 23 root shells. It had actually popped zero. 的封面图片

auto_majicly

我一直在构建一个自主渗透测试代理——一个驱动真实工具(nmap、masscan、hydra、Metasploit、searchsploit)的 LLM,围绕一个循环运行:侦察目标、选择漏洞、执行、判断是否成功,然后继续。以下所有内容都是在隔离实验室中针对故意存在漏洞的 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。我的解析器 dutifully 将这些字节捕获为模块路径的一部分,所以每个选中的模块都加载失败。Gated Metasploit 的成功率为 0%,原因在纯文本日志中是不可见的。修复:在解析前 strip_ansi()。

3. 版本字符串伪装成产品。nmap 报告 RPC 服务时版本在前:2 (RPC #100000)。我的指纹解析器抓取 2 作为产品名称,并将“2”作为搜索词提供给 Metasploit。修复:一个前导数字守卫,这样版本永远不会被误认为是产品。

4. 一个 5 分钟的挂起来自一个超时常量。call_model() 重用了 300 秒的工具超时,所以一个卡住的本地 LLM 请求会让整个任务停滞五分钟。修复:一个单独的 90 秒模型超时。

5. 一个吃掉自己输出的沙箱。代码执行沙箱在没有打印解析器期望的 EXIT 约定的情况下引发了 TimeoutExpired,所以一个慢速漏洞在耗尽整个时钟后显示为一个不透明的“No EXIT marker”。修复:捕获超时,发出一个干净的 EXIT 124 和部分输出,将默认墙时间降至 60 秒。

这些都不有趣。它们都是“工作”和“静默地什么都不做”之间的区别。它们共同削弱了代理,而日志看起来很好。

它为什么发射垃圾:相关性与相关性
随着管道的修复,代理开始接触 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 服务。每一个都是“优秀”级别的,每一个都是无意义的。

原因: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 for X11 和 vnc_keyboard_exec for VNC 幸存。它用一点召回率换取大量的精确度,当失败模式是向错误目标发射漏洞利用时,这是正确的权衡。

门揭示的一个更安静的错误
当我在那里时:r-services 模块(rsh/rlogin/rexec)位于 auxiliary/scanner/rservices/ 下,而不是 exploit/ 下。我的选择器有一个 exploits_only=True 标志,它静默地丢弃了整个辅助树——所以 rlogin 的正确模块永远不会被选择,无论在哪个目标上。我移除了该标志并切换到分层排序:exploit/ 模块优先(它们弹出 shell),辅助登录作为排名备选。没有每端口硬编码——它可以推广到 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 实际弹出之前还有模块选项调整。但它不再对我撒谎,在“23 of 23”之后,这是我最关心的功能。

专门针对隔离的自有 Metasploitable 实验室构建和测试。
如果你构建类似的东西,也把它留在你拥有的实验室里。

---
## 2 — Hacker News 版本(简短,链接优先)
> **格式:** HN 想要一个 **标题** + 一个 **url** + 可选的简短文本。将标题行用作提交标题,将 GitHub(或文章)链接放在 URL 字段中,并将正文作为第一条评论粘贴——HN 读者对作者简短的“这是故事”评论反应良好。
标题:Show HN: 我让我的 AI 渗透测试代理停止对它黑了什么撒谎
URL: https://github.com/XenoCoreGiger31/GEMMA-by-GOOGLE

**第一条评论文本:**
我一直在构建一个自主渗透测试代理——一个驱动真实工具(nmap、hydra、Metasploit、searchsploit)的 LLM,围绕一个 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 端口发射了一个“优秀”级别的 asuswrt 路由器 RCE,以及一个针对 Linux rlogin 服务的 Windows 漏洞利用。修复是只有当真实服务令牌出现在模块路径中时才保留候选。

每个修复的完整文章和代码都在仓库中。仅针对自有实验室构建和测试;漏洞利用保持人工门控。很高兴谈论假阳性类——我认为它可以推广到任何报告自己行为的代理。

进入全屏模式 退出全屏模式