资讯动态

280 万美元的教训: 当调度器信任 calldata 胜过信任转发器

发布时间:2026/10/22 13:13:23 来源:尧图企业网站定制
280 万美元的教训当调度器信任 calldata 胜过信任转发器Limit Break Payment Processor V2 这次被黑很多人第一反应是又一个忘了撤销授权的项目。但真正的故事不是这个。真正的问题是一段手写汇编代码对 Solidity 的函数参数编码方式做了一个想当然的假设然后攻击者利用这个假设把交易的最后 20 个字节换成了任意地址。这个合约早在 2024 年 10 月就被 Magic Eden 停用了。但用户当初给它的授权并没有跟着一起退休。2026 年 9 月 24 日有人注意到了这个组合把两年前的一个权限变成了零成本买 NFT 的提款机。事件经过一步一步拆开看Magic Eden 在 2024 年 2 月到 10 月期间EVM 链上的 NFT 交易走的是 Limit Break Payment Processor V2。用户为了不用每次都签名对 NFT 合集开了setApprovalForAll对 WETH 开了 ERC 20 授权让合约可以直接结算。2024 年 10 月 Magic Eden 不再走 V22026 年第一季度连 EVM 市场都关了。但链上的授权不会因为交易所搬家就消失。2026 年 9 月 24 日 13:07:59 UTC攻击者部署了一个合约从单个钱包里转走了 70 个 NFT。这些 NFT 是通过 V2 以 0 WETH 的价格买走的。涉及的合集包括 Otherdeeds、Meebits、World of Women后续交易里还有 Desperate ApeWives。攻击者不需要受害者签任何东西。整个订单是攻击者提供的V2 用来判断交易发起人的发送者身份也是攻击者伪造的。受害者唯一贡献的就是两年前给的那笔授权。交易发生大约 12 小时后才有人报告给 Yuga Labs 区块链副总裁 0xQuit。那时候攻击者已经把大部分 NFT 卖给了 Seaport 上挂着的买单大约 9 ETH 已经跨链转走。0xQuit 带队的白帽救援把 23,155 个 NFT价值超过 570 万美元转移到了安全地址。然后同样的漏洞被反过来用开始从有 WETH 授权的钱包里抽 WETH。大约 660 WETH 面临风险这场救援赛跑没跑赢。有同样问题的 V3 被 Limit Break 暂停了。V2 是不可变的没法暂停、没法打补丁、没法自毁。最后剩下的防御手段就是抢在别人之前把漏洞用掉。根因不在转发器在调度器ERC 2771 是元交易的标准。一个可信转发器调用目标合约然后把原始发送者地址附加在 calldata 的最后 20 个字节。目标合约检查msg.sender是不是可信转发器然后读那 20 个字节把它当作真正的调用者。Limit Break 的实现里有一个无需许可的转发器工厂。任何人都能克隆一个转发器每个克隆都被标记为可信。这个设计本身是安全的因为每个克隆都会老老实实把msg.sender写进后缀。问题出在 Payment Processor 自己的调度器上。在把交易委托给模块之前合约用了一个汇编修饰器把 calldata 复制到内存。这个复制操作的长度来自一个对 ABI 布局的固定假设。公开的 Payment Processor 源码里有漏洞的写法是这样的modifier delegateCall(address module, bytes4 selector, bytes calldata data) { assembly { // 魔法数字 684 字节 selector 32 字节 offset 32 字节 length let lengthWithAppendedCalldata : sub(calldatasize(), 68) let ptr : mload(0x40) mstore(ptr, selector) calldatacopy(add(ptr, 0x04), data.offset, lengthWithAppendedCalldata) mstore(0x40, add(ptr, add(0x04, lengthWithAppendedCalldata))) let result : delegatecall(gas(), module, ptr, add(lengthWithAppendedCalldata, 4), 0, 0) if iszero(result) { let size : returndatasize() returndatacopy(0, 0, size) revert(0, size) } } _; }关键在这一行calldatacopy(add(ptr, 0x04), data.offset, lengthWithAppendedCalldata)。复制的起点是data.offset这个值是从 calldata 里解码出来的调用者可以控制。复制的长度是calldatasize() - 68这里假设了 offset 那个字永远是0x20。在标准的 ABI 编码下offset 确实是0x20。载荷从长度字之后 32 字节开始复制操作刚好能把载荷加上转发器附加的 20 字节后缀一起抓进来。一切严丝合缝。但如果攻击者把 offset 编码成0载荷的起点就往前移了 32 字节。复制长度不变。于是复制操作越过了原本的边界在 calldata 结束前 32 字节就停了。转发器附加的地址被挤出了复制范围。攻击者放在后缀前面那 20 个字节变成了模块读到的发送者。说白一点转发器干了它该干的事老老实实附加了真实调用者。调度器把真实调用者扔了换成了攻击者指定的受害者地址。这是 CWE 290通过伪造进行身份验证绕过。和 OpenZeppelin 在 2023 年 12 月披露的 Multicall 漏洞属于同一类问题当时是另一个组件改写了 ERC 2771 后缀导致 thirdweb 合约里可以伪造地址。攻击者怎么把漏洞变成提款机一旦_msgSender()被伪造攻击者就能让 V2 以任意一个还有授权的钱包身份去执行交易。攻击者为受害者的 NFT 创建一个零成本 offer然后让合约以为受害者接受了这个 offer。受害者的挂单根本没被用到。取消挂单也没用因为整个订单都是攻击者写的。同样的手法反过来用在 WETH 上。伪造的受害者变成买家而不是卖家用账上还留着的 WETH 授权为攻击者的垃圾 NFT 付钱。Limit Break 和 0xQuit 都没有完整公开这条路径但救援团队是在抢着转移暴露的 WETH 时发现它的。两年的正常结算从来没踩到过 offset 为零这条路径因为没有哪个正常的编码器会这么发。这就是为什么这个 bug 扛过了多轮审计。它藏在调度器里一条正常使用永远不会走到的分支上。修复方案别再假设 ABI 布局有漏洞的代码假设calldatasize() - 68就是正确的复制长度。这个假设只在data.offset恰好是0x20的时候成立。修复思路是复制长度要从实际解码出来的数据长度推导而不是从硬编码的 calldata 算术捷径里来。修正后的修饰器长这样modifier delegateCall(address module, bytes4 selector, bytes calldata data) { assembly { // msg.data selector (4) offset (32) length (32) payload (data.length) 可选发送者 (20) // 后缀长度从实际 calldata 大小推导而不是硬编码的 68 字节假设 let appended : sub(calldatasize(), add(data.length, 68)) if gt(appended, 20) { appended : 20 } let totalLength : add(data.length, appended) let ptr : mload(0x40) mstore(ptr, selector) calldatacopy(add(ptr, 0x04), data.offset, totalLength) mstore(0x40, add(ptr, add(0x04, totalLength))) let result : delegatecall(gas(), module, ptr, add(totalLength, 4), 0, 0) if iszero(result) { let size : returndatasize() returndatacopy(0, 0, size) revert(0, size) } } _; }为什么这样改有用data.length是 Solidity 的 ABI 解码器解出来的反映的是 calldata 里声明的真实载荷长度不依赖 offset 是否规范。appended算的是声明载荷之后还有多少字节。转发调用就是 20 字节直接调用就是 0。上限卡在 20防止畸形调用把多余的垃圾数据复制进模块的 calldata。totalLength是真实载荷加真实后缀。复制不再依赖 offset 字是不是0x20。攻击者把data.offset编码成0仍然可以改变载荷的起点但复制长度现在跟着声明的data.length走。受害者地址没法再通过移动复制窗口来顶替转发器的后缀。更保守的版本可以在汇编块之前直接拒绝非规范 offsetmodifier delegateCall(address module, bytes4 selector, bytes calldata data) { if (data.offset ! 0x20) { revert InvalidCalldataOffset(data.offset); } // 下面的汇编复制使用 data.length 和实际后缀 assembly { let appended : sub(calldatasize(), add(data.length, 68)) if gt(appended, 20) { appended : 20 } let totalLength : add(data.length, appended) let ptr : mload(0x40) mstore(ptr, selector) calldatacopy(add(ptr, 0x04), data.offset, totalLength) mstore(0x40, add(ptr, add(0x04, totalLength))) let result : delegatecall(gas(), module, ptr, add(totalLength, 4), 0, 0) if iszero(result) { let size : returndatasize() returndatacopy(0, 0, size) revert(0, size) } } _; }offset 检查适合想要一道简单防线的团队。长度推导是更强的修复因为它把这个假设整个删掉了。为什么原来的审计没发现这个 bug 是手写汇编里的代码缺陷不是交易流程里的逻辑错误。它不影响正常挂单、正常 offer、正常结算。它只影响一种故意构造的畸形 calldata 编码而没有任何正常的交易平台会产出这种编码。标准的智能合约审查通常盯着 Solidity 表面权限修饰器、重入锁、签名验证、经济不变量。一个把原始 calldata 复制到内存的调度器在这些东西的下面。它需要不同的审查手段在 ERC 2771 边界上对 calldata 编码做模糊测试喂零 offset、重叠 offset、越界 offset、截断后缀。这才是能在部署前抓到这类问题的控制措施。运营方和用户该记住什么停用等于一次授权事件。当你不再把交易路由到某个结算合约就要公布地址、通知用户撤销、然后持续提醒。关掉一个市场关不掉链上的授权。要么留一个暂停开关要么留一条能关掉的授权边界。一个不可变、又握着用户资产操作授权的合约必须有办法停止结算。V3 有暂停功能Limit Break 用了。V2 没有最后防御手段变成了比谁先用漏洞。永远不要从假设的 ABI 布局推导 ERC 2771 后缀的位置。发送者要从msg.data的实际末尾读或者在复制前验证 offset。模糊测试要覆盖调度器不能只测交易逻辑。盯着退休的合约。你已经不再路由的合约上出现结算活动本身就是信号。一个已弃用的处理器上出现零价格成交就是紧急事件。用户先撤销再回收。如果你曾经授权过 Limit Break Payment Processor去以太坊、Polygon、Base 上撤销 V2去 ApeChain 上撤销 V3。撤销不会把已经转走的资产拿回来但能关掉还躺在钱包里的那些资产的窗口。Limit Break 这次被黑不是一个交易所忘了善后的故事。它提醒我们授权是给合约字节码的许可而这个许可的杀伤半径取决于持有者身上最严重的那个 bug。一个两年前的权限加上 calldata 里 32 字节的位移就足够把一个退休的结算合约变成 280 万美元的提取工具。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑