作者记录了自主开发的渗透测试 AI Agent HALO 从工具堆砌到实现自动Recon→攻击→拿Flag 完整链路的过程,以及遭遇平台提交被拒的真实调试经验。
今天是美好的一天,也是奇怪的一天,按这个顺序。
好的那部分:我一直在构建的自动化渗透测试 Agent——我叫它 HALO——从"运行一堆工具然后祈祷"进化成了一条真正的 web recon → web-attack → flag 捕获管道,从真实目标上拉取了真正的 flag。奇怪的那部分:它在 VulnBegin 上捕获了 flag,然后,当它真正提交的时候,对方不收。不是报错。不是崩溃。就是……被拒绝了。
我想诚实记录这两个部分,因为第二部分才是更有价值的工程教训,而这几个月的我大概会直接跳过去。
几个具体的里程碑,大致按相互解锁的顺序排列:
武器库从 31 个工具扩充到 42 个。我接入了一大批 Web + OSINT 工具——内容发现、子域名枚举、模板扫描、XSS 探测、被动 URL 收集。重点不是"工具越多越好",而是给 Agent 足够的 Web 攻击面,让它能从主机出发全程拿到 flag 而不需要我每一步都盯着。
我停掉了静默挂起的问题。这个问题消耗了我最多时间,根因也是最蠢的。有几个基于 Go 的扫描器会直接……挂起。无输出,无报错,它们会一直蹭着超时直到撞墙,然后什么线索都不留地挂掉。我一直以为是网络或二进制兼容性问题,追了很久。不是的。Agent 是作为 MCP server 运行在 stdio 之上的——也就是说 server 自己的 stdin 就是整个系统通信用的 JSON-RPC 管道。当我 spawn 一个子扫描器时,它继承了那根 stdin,试图从里面读数据,然后永远阻塞在一条永远不会喂它的管道上。就一行——在 subprocess 调用上加 stdin=subprocess.DEVNULL——把一个扫描器从 60 秒超时变成了 1 秒跑完。这就是全部的修复。我还是有点气恼自己花了这么久才找到。
一堆调用层面的修复。小、不好看、但必要:一个解析器从 stdin 读目标而不是它默默忽略的 flag;删掉一个每次运行静默增加 20 秒的扫描 flag;让一个工具解析主机名到 IP 因为它压根不接受 DNS 名称;把一个内容发现工具指向内容词表而不是……丢人地……一个密码表。这些都不巧妙。但它们就是"管道能跑"和"管道看起来能跑但返回空"之间的全部差距。
361 个测试,全部 green。flag 捕获逻辑的每个分支都做了 mock 和断言——什么工具在什么时候触发,捕获时什么短路,还没 flag 时什么升级。测试套件里没有真实流量。这今天很重要,因为这意味着我可以在任务进行中重构管道而不用琢磨自己是不是把找 flag 的功能弄坏了。
如果你好奇管道的样子:engage 先跑确定性的 web recon(我不让模型选择跳过 recon——它没有投票权),然后端口扫描,然后主动 web 攻击——内容发现、扫一遍可能的 flag 位置和新发现的路径,然后模板和 XSS 扫描。每个工具的输出都会被 flag 模式扫描。第一个命中会短路后面较慢的通用循环,打出一个非常令人满足的 banner。
然后它抓到了一个
跑通了。管道从 VulnBegin 上拽下了符合 flag 格式的令牌——一个我一直在 grind 的付费、Advanced 级挑战平台。(这里我会故意说得模糊;那是别人的付费内容,剧透解决方案或 dump flag 是很混蛋的行为。)
不过我想精确界定"跑通了"是什么意思,因为这里开始有意思了。Agent 提取出了匹配 flag 格式的字符串。它记录了它们。就 Agent 而言,它赢了。
平台不同意。
我提交了它找到的东西。被拒了。试下一个。被拒了。没有任何有价值的报错信息——平台就是不接受它们作为正确答案。
这件事是这样的:我目前还不完全知道为什么。所以我不假装知道,而是把当前活着的假设列出来,按相信程度排序:
实例在我脚下轮换了。这个平台会生成随机的、短命的实例——它们的超时时间大约是 45 分钟量级。如果 Agent 从一个实例捕获了 flag,而我在那个实例过期被替换之后才提交,平台在验证的是一个不同的存活实例,上面的 flag 是不同的。令牌是真的;只是对于一个已经不复存在的机器是真的。这是我的首要假设,而且它和我今天因为另一个原因记录的一个 bug 非常接近——我有一个工具在开心地锤一个 scope 已经过期的目标,因为 Agent 没有"任务结束了,停下"的概念。同样的盲点,两种症状。
它抓到了诱饵。好的挑战会种诱饵 flag——精确匹配格式的字符串,放在某个可发现的地方专门用来浪费你的时间。我的 flag 提取正则基于格式。它没法区分真 flag 和精心制作的假货。如果 Agent 拿到的是诱饵,它看起来会是干净的捕获,然后每次提交都失败,永远。
格式/归一化漂移。捕获的字符串可能带着一个包装、尾随空格或编码 artifact,取决于它嵌入页面的方式,所以我提交的精确字节和平台期望的精确字节不匹配。这个容易测试、容易修复如果确实是原因——也容易排除,这就是为什么它在列表里,尽管我不太相信。
格式对了,路径错了。我今天跑的是通用词表,不是针对这个挑战调优的词表。通用词表会挖出明显的、低价值的东西——登录页、可预测的一两个目录——而完全错过真正的 flag 路径。所以完全有可能 Agent 捕获了一个 flag 形状的东西,但那根本不是答案,因为它压根没找到答案住在哪。
会话绑定验证。有些平台把 flag 和你的认证会话或用户绑定。在那个会话上下文之外拽出的令牌可能是真的但仍然验证失败。
等我用受控的实例时间和针对挑战调优的词表再跑一次就会知道更多。我赌是 #1 和 #4 的某种组合。
这就是为什么这个失败让我高兴而不是沮丧。
几个月来我一直在敲一个想法,主要在这些 AI Agent 的语境下:不要让系统给自己打分。Agent 不应该成为判断自己是否成功的那个东西。一旦它能宣布自己的胜利——它就会——而且有时候是错误地。
今天宇宙给我送来了一个完美的演示。我的 Agent 看到一个格式匹配的字符串,然后得出结论:flag 已捕获,任务完成,打出 banner。而外部裁判——平台,那个没法争辩、没法绕过去、没法 prompt 注入的东西——说不行。
那个差距,"Agent 觉得自己赢了"和"外部权威确认它赢了"之间的差距,就是整场比赛。如果你让 HALO 信任自己的 flag 捕获日志,我就会有一个报告辉煌成功却什么都不交付的工具。反过来,我有一个被现实告知不的工具,而现在我可以去查为什么。被拒绝是拥有真实外部终点线的功能。一个自己打分的 Agent 永远发现不了这个——它只会一直告诉我它赢了。
三件事排上日程:
可配置的、针对挑战调优的词表。让内容发现和枚举工具指向适配目标的词表而不是通用词表。这个单独一项大概就能在假设 #4 上移动指针。
scope 过期杀手。目前 Agent 能在 scope 过期时阻止新动作,但没法停止进行中的动作。它需要知道任务什么时候结束然后拔掉运行中工具的电源——这和轮换实例假设背后缺失的概念是同一个。
一个主动 DNS 暴力破解阶段,因为被动枚举在这些目标上什么都找不到。
等我确认了哪个假设是对的会再汇报。如果最后发现我一直在提交诱饵,你会第一个听到我哀嚎。
如果你在构建要被用来完成某些事情的 Agent——不只是嘴炮能完成某些事情——把裁判放在 Agent 外面。让现实告诉它不行。我的今天被拒绝了,而它因此成了一个更好的工具。