作者在构建安全 Agent 时发现测试通过但实战失效的根本原因:测试数据与真实目标分布不一致,并详细阐述了闭环设计、终止条件和重复检测的实现思路。
我花大部分时间在构建一个自主安全 Agent——它对我授权测试的目标进行侦察,选择漏洞利用方式,并尝试证明访问权限,全程针对我自己的实验靶机进行。本周我重建了它的核心逻辑,看着每个测试都通过了,拿它对一个真实目标运行,结果一个 shell 都没拿到。不是因为模型太笨。是因为我的测试一直在悄悄对我撒谎。又来了……所以,这怎么可能……又来了?
以下是完整的故事,因为这个错误比任何功能都更有价值。
旧设计是一个冻结的计划。规划器在开始时一次性将目标分解为一个有序的子任务列表,执行器按顺序遍历这个列表。这样在现实与计划不符时就出问题了——一个意料之外的端口、一个失败的漏洞利用、一个与横幅声称不符的服务。冻结的列表无法随机应变,它只会一路向前。
所以我用一个闭环替代了它。每一次迭代,策略获得完整的当前状态加上所有已经失败的信息,然后选择一个下一步动作。执行,将结果折叠回信念状态,重新规划。规划器不再是一次性分解器,而是变成了每步策略。
我最关心的部分不是这个循环——而是"牙齿"和检测手段,因为一个无法停止或无法被测量的自主系统是负担,不是工具:
终止牙齿,在每个动作前检查:迭代预算、墙钟截止时间、终止开关,以及停顿检测器(信念状态停止变化→停止)。加上重复守卫:如果策略重新发出一个已经试过的动作,不要重新运行它——将其计为一次重复。
适应度分数。每次重新规划都记录新动作是真正新颖的还是只是已经试过的东西的重新排列。一轮运行下来你会得到新颖/总数的比值。核心目的:衡量模型是否真的在适应,而不是假设它会。一个能让我尴尬的数字胜过十个恭维我的数字。诚实的 breach 确认。成功从不是模型说了算。只有在模型无法控制的代码检查到真实证据时,breach 才算数。不要让系统给自己的作业打分。
第一次对实验目标进行真实运行,模型正常侦察,然后开始处理端口。在一个本该是整台机器最容易拿下的端口上——一个有已知后门的服务,那种通过老路径每次都能弹 shell 的东西——日志里写着:
Nothing worked on port 21.
零 breach。超过十六次迭代。以下部分让我没有误判错东西:模型选对了目标。它正确识别了有漏洞的服务并选择攻击它。它的判断没问题。是下游什么东西吃掉了胜利。
老的、经过验证的路径通过挑战-响应 nonce 确认这个漏洞利用。Agent 生成了一个一次性令牌,漏洞利用必须在结构化的证据行中回显那个令牌作为它实际运行了我们命令的证明——不是横幅,不是回显的字符串,而是一个绑定到那一次尝试的执行派生事实。这就是反作弊机制,阻止 tarpit 或反射器伪造 shell。
新的门控攻击路径……从未生成 nonce。所以漏洞利用触发了,生成了其 nonce 绑定的证明,但确认器没有任何东西可以检查。没有 token 进去,就没有确认出来。"什么都没管用。"
但我的测试是绿的。为什么?
因为测试向确认器发送了原始的 uid=0(root) 字符串并断言"breach 已确认"。这在以前是对的——在 nonce 硬化之前。之后,当真正的漏洞利用停止发射裸露的 uid=0 横幅而开始发射结构化的、nonce 绑定的行时,测试继续使用旧的假证据。所以它们执行了一个不再匹配现实中的代码路径,并以完全自信的姿态通过了。
单元测试测试的不是系统。它们测试的是系统的博物馆展览。
当我追踪时,同样的缺失 nonce 逻辑坐落在另一个我以为已经工作了数周的执行路径中。它在数月前的演示运行中"弹出了 shell"——在 nonce 硬化落地之前。之后没有人重新检查它。它的测试也是绿的,同样的假证据原因。所以我以为只局限在我新代码中的一个 bug 悄悄地禁用了两个路径的 breach 确认,而我的整个测试套件都让它通过了。
一次对真实目标的真实运行发现了绿色套件隐藏了数周的东西。
没什么花哨的。门控路径现在像经过验证的路径一直做的那样生成并注入 nonce,并通过它确认。下游的重新检查无法重新消费一个一次性 token(这正是一次性的意义),所以它验证结构化证据工件的存在,而不是要求原始横幅。然后我重写了每个一直在喂养假 uid=0 字符串的测试,让它们通过 nonce 来证明 breach——就像真实漏洞利用实际做的那样。
然后我重新运行它。端口 21 命中了。诚实的、门控的、端到端的。
通过的测试证明你的代码匹配你的测试,不证明它匹配现实。 我的测试匹配了一个悄悄变旧的 fixture。如果断言硬编码了"成功"长什么样,那个字符串就成了你现在必须保持诚实的第二个真相来源。将测试绑定到你系统产生的真实工件上,否则它们会在保持绿色的同时腐烂成 fiction。
活的靶机是你的单元测试无法替代的外部裁判。 我的整个设计都指向不让 Agent 给自己的作业打分——nonce、证据检查、模型无法触及的确认器。但我让测试套件用自己的假证据给自己的作业打分。真实的机器不在乎我的 fixture 断言什么。这正是它有价值的原因。
为让你害怕的答案做检测。 适应度分数的存在是为了大声告诉我,我的本地模型是否只是在重新洗牌而不是真正推理。我还没有得到一个干净的数字——运行一直在首先暴露 bug,而且模型足够慢,我下一步要限制生成长度。但我在构建能让我尴尬的东西之前构建了能让我印象深刻的东西,而这个顺序是今天以真实 shell 而不是假 shell 结束的唯一原因。
本周我没有得到我想要的标题数字。我得到了更好的东西:一个诚实失败的系统、终于匹配现实的测试,以及一个非常具体的提醒:"全绿"是对我测试的主张,不是对我代码的主张。
零 shell 比三个你无法信任的 shell 更好的老师。
这里的一切都运行在我拥有并授权测试的目标上。如果你在构建在世界上行动的 Agent,在你构建做主张的部分之前,先构建能证明——或反驳——那个主张的部分。