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 不再走 V2,2026 年第一季度连 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 { // 魔法数字 68:4 字节 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走。受害者地址没法再通过移动复制窗口来顶替转发器的后缀。
更保守的版本可以在汇编块之前直接拒绝非规范 offset:
modifier 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 万美元的提取工具。