本周挂出的一篇预印本报告说:在某个智能体运行时连续七个版本里,人可以批准一笔 40 美元的退款,而系统执行的却是一次完全不同的调用——原因不是有什么东西被注入进了模型,而是这次批准被存成了一个挂在标识符上的布尔值,参数却留在任何人仍能写入的状态里。如果你的审批关卡是有后果的动作之前的最后一道控制,那么它必须回答的问题就不是有没有人点了同意,而是同意的究竟是什么,以及那个东西还是不是即将运行的那一个。
速览
Loopjacking:劫持人在回路的审批(arXiv 2609.21081)把这种失败定义为实现层面的失败:人批准的是他所理解的那个操作或表示 A,而产品自有的逻辑随后拿这个决定去授权或放行一个实质不同的 B。作者同时发布了一份复现存档,证据截止日期为 2026 年 9 月 10 日。
| 运行时 | 所测版本 | 结果 |
|---|---|---|
| Agno AgentOS | 2.5.5 – 3.0.9 | 批准后的状态替换在全部七个版本上均被复现。 |
| LangGraph Agent Server | 0.7.4 – 0.14.0 | 在一种条件式内存内组合上复现;结果取决于配置。 |
| OpenClaw | 2026.2.23 | 表示不一致被复现;在 2026.2.24 中修复。 |
| OpenAI Agents SDK | 0.22.0、0.22.2 | 阴性对照——序列化的续行保留了逐次调用的绑定,拒绝了被篡改的调用。 |
把这张表读成关于设计的陈述,而不是关于厂商的陈述。其中三个运行时把待执行动作放在可变的服务端状态里、并让批准以键关联到它;一个则把确切的调用序列化进续行,并在恢复执行时做比对。正是这一处架构差异,把被复现的那几行与被拒绝的那一行分开了——而这是任何团队今天下午就能在自己的编排代码里做出的选择。
绑定断裂的两种方式
点击之前的表示不一致
那个有后果的细节其实已经编码在待执行的调用里,却被审阅者所看到的内容省略了、或被它错误地呈现了。审批界面渲染的是一份摘要——一句话、一张卡片、一段差异——而这份渲染是由产品代码生成的,是它决定了哪些字段要紧。它漏掉的任何字段,都是人没有批准、却即将被授权的字段。这就是击中 OpenClaw 2026.2.23、并在一天后被修复的那个变体。
值得把话说准:这不是一个 UX 缺陷。一份省略了字段的摘要,是一项安全控制中的正确性失败——就像对文档一部分的签名并不是对该文档的签名一样。审阅者的决定是针对一份渲染做出的,而渲染并不是那个会被执行的产物。
点击之后的状态替换
更棘手的变体,也是复现得最广的那个。人看到了正确的 A 并批准了它。批准被记录成挂在一个请求标识符上的状态标志。而参数留在仍可写入的工作流状态里——任何拥有通往该状态写入路径的东西,都能在执行器把它们读回来之前替换掉它们。一旦如此,执行器找到的是一条标着「已批准」的记录,然后运行里面当下装着的东西。
请注意这段描述里缺了什么:没有提示词注入、没有沙箱逃逸、没有权限提升,根本没有任何模型的不当行为。模型可以完美对齐,每一道护栏都可以通过。系统做的正是它代码里写的事,而它的代码写的是——先检查标志,再读取载荷——两个分开的操作,中间有一道缝。这是一个恰好中间夹了个人的「检查时到使用时」缺陷,这也正是为什么通常那套智能体安全工具箱(见我们的注入防御与隔离模式)对它完全无能为力。
为什么批准是一次绑定,而不是一个布尔值
多数智能体框架处理人工审批的方式,跟它们处理任何其他中断一样:暂停图、抛出一个请求、等待恢复信号、继续。恢复信号带着一个决定。而在多数实现里,它并不带着被决定的那个东西——那被默认为可以从状态里取回来,因为其他一切都住在状态里。
失效的正是这个默认假设。批准是一句关于某个具体动作的陈述,而一句关于具体动作的陈述,必须以一种能扛住后续一切的方式附着在那个动作上。数据库层面的类比是乐观并发:你不会重新读一行就盲写下去,你会在提交前比对一个版本号。密码学层面的类比更贴切——批准就是一次签名,而一个覆盖标识符而非载荷的签名,保护不了载荷。
落到实处就是内容寻址。把确切的调用规范化——工具名、每一个参数、它将以何种主体身份运行、它将触及哪项资源——做哈希,把从同一份规范形式生成的渲染呈现给人,并把批准存在这个摘要值上。执行时,把即将运行的东西重新规范化并比对。摘要值不一致就拒绝并重新征询。这正是论文的缓解一节所说的「完整的规范化审批渲染加上使用时的精确比对」,而它给出的另一条路——阻止对待执行状态的未授权改写——是从另一侧抵达的同一份保证。
这件事改变了你该怎么测审批
令人不安的推论是:没有谁现有的测试套件覆盖得到这一点。审批关卡被测的是顺利路径(批准,它执行)和不顺利路径(拒绝,它不执行),而上表里每一个有问题的实现,这两条都能通过。会失败的那条测试是没人写的那条:批准、改写、然后执行。
- 批准后改写。批准一次金额很小的调用,通过你系统暴露的任何路径——第二轮对话、排队中的消息、一个能编辑草稿的工具——往待执行记录里写入另一份载荷,然后断言执行器拒绝。这是三行测试,而它就是这项发现的全部。
- 渲染与载荷的差异。断言将要发出的调用的每一个字段,都出现在了人所看到的文字里。不是断言摘要准确——是断言字段集合完整。渲染器丢掉的字段,就是批准范围之外的字段。
- 过期令牌重放。拿一次已完成动作的批准再提交一遍。它应当一次性使用,并且只作用于一个摘要值。
- 使用时的主体检查。批准者的权限应当在执行时重新评估,而不只是在提出请求时。这与委托访问与同意记录是同一个论证:一份没说清它同意了什么的同意记录,不能证明任何事。
如果你在跑一个有一定量的审批队列,本文里价值最高的单项就是那条「批准后改写」的测试。今天就把它写了。
能推广到这四个运行时之外的部分
人在回路,是当智能体拿到某样昂贵东西的访问权时人人都会去摸的那道控制。它是厂商文档里的答案、风险登记册里的答案、每一份威胁模型缓解栏里的答案,以及相当多合规叙事里的答案。Loopjacking 提醒我们:它是一道有实现的控制,而它的实现所受到的审视,只是沙箱与注入防御所受审视的一个零头。
这种不对称在历史上说得通,如今却不再站得住。智能体栈里真正要紧的攻击面,一直在稳步地远离模型、走向它周围的管道——授权的接合处、状态的处理、以及模型所提议的与系统所提交的之间那道边界。我们就智能体 CVE 本质上是授权缺陷、以及单次运行里的读与写做过同样的论证。一次不做绑定的批准是又一个实例:一道在架构图里真实存在、在代码路径里并不存在的控制。
好消息是这个修复很小、很局部、也可测,而且样本里已经有一个运行时本来就做对了。把被批准的调用序列化下来、并在恢复执行时做比对,不是一个研究问题。那是一个下午的工作量,而它把批准从一句关于意图的声明,变成一句关于某个具体动作的声明——也就是所有人本来就以为自己拿到的东西。
常见问题
这是 Agno 或 LangGraph 的漏洞吗?
论文把它表述为一种在特定版本与特定组合上复现出来的实现层面失败模式,而不是某一个产品的缺陷——LangGraph 那条结果明确取决于所用的配置。把这张表当成一份「去自己栈里核查哪些设计」的地图,而不是对某个厂商的判决;在对当前版本下任何结论之前,先看该项目最新的发布说明。
这是提示词注入造成的吗?
不是,而这正是要点。注入是攻击者抵达待执行状态写入路径的一种方式,但这个失败根本不需要模型有任何不当行为——两条合法代码路径之间的一次竞态就足以产生它。修好你的注入防御,修不好这个。
我们用的是独立的审批服务,是不是就安全了?
只有当批准绑定的是调用的内容而不是一个请求 id、并且执行器在使用时重新比对,才算安全。一个返回「请求 7f3a:已批准」的独立服务,只是把同一道缝挪到了另一跳网络上。
最小可行的修复是什么?
把调用规范化、做哈希、把批准存在摘要值上,并在执行前立即比对摘要值。不一致就拒绝并重新征询人的意见。把审批令牌做成一次性的。
这会影响基于 MCP 的工具调用吗?
它影响的是谁拥有那道审批关卡,而那是宿主或客户端,不是服务器。如果你的宿主渲染一份摘要、并按标识符恢复执行,那么无论传输层是什么,这个模式都适用。
延伸阅读
本站:
- 人在回路与最小权限——审批关卡该放在哪里、哪些动作配得上一道。
- 审批与确认的 UX——设计审阅者真正会读的那份渲染。
- 指令层级——为什么权限必须是结构性的而不是文本性的。
- 决策回执与审计——让一份决策记录说清它究竟决定了什么。
- 爆炸半径——给这道关卡所挡在前面的后果定个量级。
来源:
- Loopjacking: Hijacking Human-in-the-Loop Approval——arXiv 预印本 2609.21081。
- adithyan-ak/loopjacking——证据与复现存档。