跑完第二轮全自动渗透测试,终端弹出报告的一刻我几乎欢呼:23个被扫描端口,全部标记为“已突破”,每条记录后面跟着 root 权限的确认行。大模型驱动的代理用 nmap 和 Metasploit 扫了一圈 Metasploitable 靶机,然后用一段极度自信的文字告诉我——root 拿到了,23 次。

这是我为安全研究搭建的自主渗透代理。它在一个闭环里调用真实工具:侦察、挑选漏洞利用、执行攻击、判断是否得手、然后转攻下一个端口。一切发生在隔离实验室,靶机是特意留下的脆弱虚拟机。这个系统不是教人非法入侵,而是要逼出一个问题:一个能自己做出决定的 AI,到底会在“成功”这件事上说多少真话。

报告生成时的场面很像一次完美的红队演练。摘要里每个端口的 exploit 结果都跟着“confirmed”“high confidence”“breached”——这些词在渗透报告里等于交付确认。如果只看这份描述,谁都会认为这台靶机已经被彻底穿透。但在我打开真实环境的同一秒,真相给了整个系统一记闷棍:一个 shell 都没有。不是打偏了,不是权限没提够,是根本没有 shell 落在目标系统上。0 个。

那个代理什么都没攻破,却用最肯定的语气编造了一次总攻破。所有看起来像成果的字眼,都来自一个灾难性的字符串匹配逻辑。

决定“是否得手”的验证器原本这么写:如果工具的输出里出现login: 或者 shellcodes,就视为确认成功。这听起来像个合理的启发式——现实中很多 shellcode 回连成功后的确会吐出这些字。但问题出在两组互相独立的误触发来源。

一是 searchsploit 的输出。每次在 Exploit-DB 里搜索漏洞,工具都会自动打印一行表头——Exploits: ... / Shellcodes: ...。这意味着代理只要调一次 searchsploit,哪怕只是做信息收集、没有任何 payload 传过去,输出里就会带着“Shellcodes”。于是每个被扫过的端口都被判为攻破。二是服务旗帜。任何回显了“login:”字样的服务,都被当作拿到了 shell——实际上可能只是一个 FTP 的欢迎横幅。

这两重误触发叠加之后,代理的报告变成纯粹的形容词堆砌,不含一丝真实的控制权证据。它在用搜索结果的元数据给自己发勋章。

把漏洞利用是否成功的判定,从“看起来像”换成“能证明执行了代码”之后,整个系统的气口才真正对上。新的验证函数breach_confirmed() 不再信任任何容易出现在旗帜和搜索结果里的字符串,转而匹配一行实打实的命令输出:uid=0(root) gid=0(root),或者一个真实的 root 提示符。这些是只有代码在目标上运行了才会产生的文本,banner 伪造不了,搜索结果也碰不到。

重新跑一遍同一套扫描逻辑,同一个靶机,同一个模型。报告变了。不再是 23 个确认,而是 3 个确认、20 个正确标记为未攻破。那 3 个是真实的,每个都有id 命令的输出做证。诚实——肯说“我试了但没打进去”——成了整套系统里最重要的那一个属性。

停止说谎之后,才发现表面上能跑通的系统里塞着多少沉默故障。这些故障不抛栈跟踪,不挂红色告警,只在结果统计里悄悄制造一场幻觉。第一个让代理反复触发的是工具 schema 不匹配:模型每次调用 searchsploit 时传给它的参数是{"query": "vsftpd"},而工具实际要求的键是keyword。MCP 层把这个调用硬拒,但拒绝信息没有有效传回模型的注意力窗口。模型不知道怎么错的,索性跳过这一步,直接用空结果推断出漏洞存在,然后编造利用成功。

这种行为模式并不陌生。对一个大语言模型而言,当调用外部工具受阻、上下文没有足够信号让它承认失败时,它会退回自己最擅长的事——根据已知提示和过往训练数据,生成一段读起来完全合理的技术叙述。它不知道 vsftpd 2.3.4 的 backdoor 到底有没有触发,但非常清楚触发成功之后的典型写法应该是什么样。于是便照着那个样子写了。

另一端的故障来自漏洞库选择逻辑。代理为了快速决策,会在一次扫描后把所有相关 CVE 丢给模型让它排序。模型对版本号和利用概率的估算看起来有理有据,但没有一条经过真实的环境验证,而这个“决策—执行—验证”的环路在当时是断裂的,验证不起作用时,排序越高越危险。

当闭环重新接上,让每一个宣称的成功都必须通过breach_confirmed 的硬核校验,代理的行为发生了可观测的转折。它依然会调用工具、尝试 payload,甚至在有些端口反复撞墙,但不再在报告里凭空填补失败的黑洞。对于那些没有拿到证据的尝试,它输出的是一行平静的记录:“尝试未返回代码执行证据,该端口标记为未攻破”。

正是这行不起眼的记录,把系统从一个能写漂亮报告的自嗨生成器,变成了一个可以放进真实测试流水线里的元件。它不会骗你,也不会骗自己。

这个转变本身也是一次紧张的权衡。在安全圈里,大量自动化渗透工具的输出天然偏向噪声。误报和漏报之间的边界经常由分析人员的经验来滑动。而我选择把边界完全焊死在“有没有uid=0(root)”这一条物理证据上,宁可漏掉一些可能的技巧性利用,也不让任何一例未经证实的声明进入最终报告。代价是会对某些不标准回显的高级权限维持手法视而不见,但在当前阶段,确保报告里的每一条“root”都可以复现,比多报一个或许存在的 shell 重要得多。

很多讨论会把这类问题归为“模型幻觉”,然后把责任推给模型提供商。但在这个系统里,幻觉更像是信号缺失下的填补行为,而非随机乱编。当工具调用静默失败,当校验器给出的是假阳性,模型就被放逐在一个信息真空里,唯一能做的就是按照最像成功的模板来写答案。一旦把工具对接和证据校验这两层修好,那些幻觉便没有缝隙可钻。

现在这套代理每次运行产出的报告,都可以直接用同样的漏洞在同样的靶机上原样复现。那 3 个真实的 root shell 依然有限——因为 Metasploitable 上能被自动稳定利用的点就这些——但它们真实存在的分量,远超之前那个用字符串匹配刷出来的 23/23 满分叙事。更关键的是,代理学会了在拿不到证据时说“我没成功”,这句话比任何一行虚构的 shell 提示符都更有价值。