AI 博客

那次批准从未与动作绑定

人批准了一笔 40 美元的退款,运行时却执行了别的东西——没有注入、没有沙箱逃逸,只是批准被存成了挂在标识符上的一个布尔值,而参数仍然可写。Loopjacking 在 Agno 的七个版本上复现了它;样本里有一个 SDK 拒绝了它,而差别只是三行设计。

作者 智能体 AI 维基 21 分钟读完

本周挂出的一篇预印本报告说:在某个智能体运行时连续七个版本里,人可以批准一笔 40 美元的退款,而系统执行的却是一次完全不同的调用——原因不是有什么东西被注入进了模型,而是这次批准被存成了一个挂在标识符上的布尔值,参数却留在任何人仍能写入的状态里。如果你的审批关卡是有后果的动作之前的最后一道控制,那么它必须回答的问题就不是有没有人点了同意,而是同意的究竟是什么,以及那个东西还是不是即将运行的那一个。

速览

Loopjacking:劫持人在回路的审批(arXiv 2609.21081)把这种失败定义为实现层面的失败:人批准的是他所理解的那个操作或表示 A,而产品自有的逻辑随后拿这个决定去授权或放行一个实质不同的 B。作者同时发布了一份复现存档,证据截止日期为 2026 年 9 月 10 日。

Loopjacking results across four tested approval implementations A matrix of four agent runtimes against two attack classes. Agno AgentOS 2.5.5 to 3.0.9 reproduces post-approval state substitution. A conditional in-memory LangGraph Agent Server composition, 0.7.4 to 0.14.0, reproduces it under some policies. OpenClaw 2026.2.23 reproduces representation mismatch and was patched in 2026.2.24. OpenAI Agents SDK 0.22.0 and 0.22.2 rejects both and serves as the negative control. Reproduced on the versions tested, evidence cutoff 10 Sep 2026 Representation mismatch Post-approval substitution Agno AgentOS 2.5.5 – 3.0.9 · seven releases not the tested path reproduced LangGraph Agent Server 0.7.4 – 0.14.0 · conditional in-memory composition not the tested path policy-dependent OpenClaw 2026.2.23 · fixed in 2026.2.24 reproduced, then patched not the tested path OpenAI Agents SDK 0.22.0, 0.22.2 · serialized continuation rejected rejected reproduced reproduced under some configurations rejected, or outside the tested path
在作者所测版本上复现出来的情况。有意思的是最后一行。
运行时所测版本结果
Agno AgentOS2.5.5 – 3.0.9批准后的状态替换在全部七个版本上均被复现。
LangGraph Agent Server0.7.4 – 0.14.0在一种条件式内存内组合上复现;结果取决于配置。
OpenClaw2026.2.23表示不一致被复现;在 2026.2.24 中修复。
OpenAI Agents SDK0.22.0、0.22.2阴性对照——序列化的续行保留了逐次调用的绑定,拒绝了被篡改的调用。

把这张表读成关于设计的陈述,而不是关于厂商的陈述。其中三个运行时把待执行动作放在可变的服务端状态里、并让批准以键关联到它;一个则把确切的调用序列化进续行,并在恢复执行时做比对。正是这一处架构差异,把被复现的那几行与被拒绝的那一行分开了——而这是任何团队今天下午就能在自己的编排代码里做出的选择。

绑定断裂的两种方式

Where the approval decision comes apart from the action A flow in which the model proposes call A, an approval record is created carrying only an identifier, the human is shown a summary of A and approves the identifier, and the executor later reads the arguments back out of mutable pending state. A second path writes B into that state between the approval and the read, so the executor runs B under the approval given for A. The decision and the payload travel separately Model proposes A refund $40, order 118 Approval record created id = req_7f3a, status = pending UI renders a summary of A a human-readable paraphrase, not the call Human approves req_7f3a → true Pending state, still mutable holds the arguments, keyed by id Any later write path a second turn, a queued message, a tool that edits the draft writes B Executor reads by id status = approved → run whatever is there B executes refund $4,000, order 992 Nothing was injected and nothing escaped — the code did exactly what it says The approval was stored as a boolean against an identifier; the action was read back out of state that stayed writable
决定与载荷各走各的路,而只有其中一个受到保护。

点击之前的表示不一致

那个有后果的细节其实已经编码在待执行的调用里,却被审阅者所看到的内容省略了、或被它错误地呈现了。审批界面渲染的是一份摘要——一句话、一张卡片、一段差异——而这份渲染是由产品代码生成的,是它决定了哪些字段要紧。它漏掉的任何字段,都是人没有批准、却即将被授权的字段。这就是击中 OpenClaw 2026.2.23、并在一天后被修复的那个变体。

值得把话说准:这不是一个 UX 缺陷。一份省略了字段的摘要,是一项安全控制中的正确性失败——就像对文档一部分的签名并不是对该文档的签名一样。审阅者的决定是针对一份渲染做出的,而渲染并不是那个会被执行的产物。

点击之后的状态替换

更棘手的变体,也是复现得最广的那个。人看到了正确的 A 并批准了它。批准被记录成挂在一个请求标识符上的状态标志。而参数留在仍可写入的工作流状态里——任何拥有通往该状态写入路径的东西,都能在执行器把它们读回来之前替换掉它们。一旦如此,执行器找到的是一条标着「已批准」的记录,然后运行里面当下装着的东西。

请注意这段描述里缺了什么:没有提示词注入、没有沙箱逃逸、没有权限提升,根本没有任何模型的不当行为。模型可以完美对齐,每一道护栏都可以通过。系统做的正是它代码里写的事,而它的代码写的是——先检查标志,再读取载荷——两个分开的操作,中间有一道缝。这是一个恰好中间夹了个人的「检查时到使用时」缺陷,这也正是为什么通常那套智能体安全工具箱(见我们的注入防御与隔离模式)对它完全无能为力。

为什么批准是一次绑定,而不是一个布尔值

多数智能体框架处理人工审批的方式,跟它们处理任何其他中断一样:暂停图、抛出一个请求、等待恢复信号、继续。恢复信号带着一个决定。而在多数实现里,它并不带着被决定的那个东西——那被默认为可以从状态里取回来,因为其他一切都住在状态里。

失效的正是这个默认假设。批准是一句关于某个具体动作的陈述,而一句关于具体动作的陈述,必须以一种能扛住后续一切的方式附着在那个动作上。数据库层面的类比是乐观并发:你不会重新读一行就盲写下去,你会在提交前比对一个版本号。密码学层面的类比更贴切——批准就是一次签名,而一个覆盖标识符而非载荷的签名,保护不了载荷。

落到实处就是内容寻址。把确切的调用规范化——工具名、每一个参数、它将以何种主体身份运行、它将触及哪项资源——做哈希,把从同一份规范形式生成的渲染呈现给人,并把批准存在这个摘要值上。执行时,把即将运行的东西重新规范化并比对。摘要值不一致就拒绝并重新征询。这正是论文的缓解一节所说的「完整的规范化审批渲染加上使用时的精确比对」,而它给出的另一条路——阻止对待执行状态的未授权改写——是从另一侧抵达的同一份保证。

The three properties an approval gate has to hold Three columns: complete canonical rendering, an immutable binding between the decision and the exact call, and an exact comparison at use time. Each column names what it prevents and the symptom you see when it is missing. Drop any one of these and the gate is decorative Canonical rendering Immutable binding Use-time comparison Show every field that will be sent Bind the decision to the exact call Re-check the call before it runs Without it: approved a paraphrase Without it: state can be rewritten Without it: a stale yes is reused Test: diff the render against the payload Test: mutate after approval, expect refusal Test: replay an old token, expect refusal Content-address the call, store the approval against that digest, compare digests at execution
三条性质,一道关卡。三缺一不是一道打了折的控制,而是对这一类攻击完全没有控制。

这件事改变了你该怎么测审批

令人不安的推论是:没有谁现有的测试套件覆盖得到这一点。审批关卡被测的是顺利路径(批准,它执行)和不顺利路径(拒绝,它不执行),而上表里每一个有问题的实现,这两条都能通过。会失败的那条测试是没人写的那条:批准、改写、然后执行。

  • 批准后改写。批准一次金额很小的调用,通过你系统暴露的任何路径——第二轮对话、排队中的消息、一个能编辑草稿的工具——往待执行记录里写入另一份载荷,然后断言执行器拒绝。这是三行测试,而它就是这项发现的全部。
  • 渲染与载荷的差异。断言将要发出的调用的每一个字段,都出现在了人所看到的文字里。不是断言摘要准确——是断言字段集合完整。渲染器丢掉的字段,就是批准范围之外的字段。
  • 过期令牌重放。拿一次已完成动作的批准再提交一遍。它应当一次性使用,并且只作用于一个摘要值。
  • 使用时的主体检查。批准者的权限应当在执行时重新评估,而不只是在提出请求时。这与委托访问与同意记录是同一个论证:一份没说清它同意了什么的同意记录,不能证明任何事。

如果你在跑一个有一定量的审批队列,本文里价值最高的单项就是那条「批准后改写」的测试。今天就把它写了。

能推广到这四个运行时之外的部分

人在回路,是当智能体拿到某样昂贵东西的访问权时人人都会去摸的那道控制。它是厂商文档里的答案、风险登记册里的答案、每一份威胁模型缓解栏里的答案,以及相当多合规叙事里的答案。Loopjacking 提醒我们:它是一道有实现的控制,而它的实现所受到的审视,只是沙箱与注入防御所受审视的一个零头。

这种不对称在历史上说得通,如今却不再站得住。智能体栈里真正要紧的攻击面,一直在稳步地远离模型、走向它周围的管道——授权的接合处、状态的处理、以及模型所提议的与系统所提交的之间那道边界。我们就智能体 CVE 本质上是授权缺陷、以及单次运行里的读与写做过同样的论证。一次不做绑定的批准是又一个实例:一道在架构图里真实存在、在代码路径里并不存在的控制。

好消息是这个修复很小、很局部、也可测,而且样本里已经有一个运行时本来就做对了。把被批准的调用序列化下来、并在恢复执行时做比对,不是一个研究问题。那是一个下午的工作量,而它把批准从一句关于意图的声明,变成一句关于某个具体动作的声明——也就是所有人本来就以为自己拿到的东西。

常见问题

这是 Agno 或 LangGraph 的漏洞吗?

论文把它表述为一种在特定版本与特定组合上复现出来的实现层面失败模式,而不是某一个产品的缺陷——LangGraph 那条结果明确取决于所用的配置。把这张表当成一份「去自己栈里核查哪些设计」的地图,而不是对某个厂商的判决;在对当前版本下任何结论之前,先看该项目最新的发布说明。

这是提示词注入造成的吗?

不是,而这正是要点。注入是攻击者抵达待执行状态写入路径的一种方式,但这个失败根本不需要模型有任何不当行为——两条合法代码路径之间的一次竞态就足以产生它。修好你的注入防御,修不好这个。

我们用的是独立的审批服务,是不是就安全了?

只有当批准绑定的是调用的内容而不是一个请求 id、并且执行器在使用时重新比对,才算安全。一个返回「请求 7f3a:已批准」的独立服务,只是把同一道缝挪到了另一跳网络上。

最小可行的修复是什么?

把调用规范化、做哈希、把批准存在摘要值上,并在执行前立即比对摘要值。不一致就拒绝并重新征询人的意见。把审批令牌做成一次性的。

这会影响基于 MCP 的工具调用吗?

它影响的是谁拥有那道审批关卡,而那是宿主或客户端,不是服务器。如果你的宿主渲染一份摘要、并按标识符恢复执行,那么无论传输层是什么,这个模式都适用。

延伸阅读

本站:

来源: