ERC-4337 UserOperation 打包漏洞
作者:Adam Egyed
其他贡献者: fangting@alchemy.com、@drortirosh、@Gooong、@taylorjdawson、@leekt、@livingrockrises
概述
2023 年 3 月 7 日,Alchemy 及开源开发者社区的其他成员,包括 @Gooong、@taylorjdawson、@leekt 和 @livingrockrises,发现了 ERC-4337 EntryPoint 合约以及示例 VerifyingPaymaster 合约存在 calldata 解码问题。
这些合约目前已部署到多条链上,用于对 user operations 生成哈希。该实现导致哈希值随签名方式的不同而不一致,这可能带来若干二阶影响,例如同一个 UserOperation 产生不同哈希、不同的 UserOperation 产生相同哈希。
讨论由 @drortirosh 推动,并记录在这个 Github issue 中。
详细分析
以下是对受影响代码的分析,包括 EntryPoint 打包漏洞、VerifyingPaymaster 打包漏洞的说明,以及它们各自的影响。
受影响的代码
涉及的代码段如下:
UserOperation.sol:61-75
VerifyingPaymaster.sol:35-49
作为背景说明,UserOperation 结构体的定义如下:
这两段代码都使用汇编来将 calldata 的一大部分复制到内存中,目的是捕获要哈希的 user operation 的部分内容。
UserOperationLib 中的 pack 方法旨在捕获 user operation 从 sender 到 maxPriorityFeePerGas 的所有字段,包括可变长度字段(在 ABI 编码中称为动态字段)initCode、callData 和 paymasterAndData。
VerifyingPaymaster 中的 pack 方法包含除 paymasterAndData 字段外的所有这些字段,因为该字段尚未定义。
为实现这一点,两个方法都使用了 Yul 为 calldata 中的动态类型提供的一个便利字段,名为 .offset。这指的是结构体 ABI 编码中提供的值,定义见此处 Solidity 规范。(它实际上指的是偏移量之后的那个内存字,但这只是为了便于加载。)
标准的 ABI 编码器会按照字段出现的顺序对动态字段的值进行编码(在 ABI 编码器中称为它们的 tail)。
考虑以下可能生成的 user operation 在 calldata 中的编码:
💡 注意: 为简洁起见,此示例展示的 user operation 中所有动态字段长度均小于一个字。
💡 **注意: 此处的内存地址空间是在 user operation 结构体内部。在实际的 calldata 中,由于方法选择器和参数元组占用的空间,它会被放置在其他位置。
在此示例中,按照 pnd.offset 来生成 user operation 的打包结果,会得到 calldata 的如下“切片”:
这正是我们想要的结果!
然而,使用 ABI 编码参数的合约并不会校验字段的定义顺序,甚至不会校验偏移量是否有效。
使用 signature.offset 或 pnd.offset 会直接从 calldata 中读取对应的“偏移量”值。
利用这一点作为边界,就可以构造出具有异常哈希特性、但在 calldata 中仍然有效的 user operation 表示形式。
接下来让我们分别探讨这对 EntryPoint 和 VerifyingPaymaster 的影响。
EntryPoint 打包漏洞
为演示这个漏洞,我们需要考虑一个与提供的 SimpleAccount.sol 不同的钱包合约,因为该示例复用了 EntryPoint 中存在漏洞的代码。
当 EntryPoint 与钱包合约之间使用不同的哈希方案时,或者钱包对非标准的 user operation 编码进行签名时,哈希差异就会产生实质影响。
这给 EntryPoint 带来的风险是:单个 user operation 可以对应多个“user op hash”,同一个“user op hash”也可以对应多个 user operation。
考虑这个账户,称为 ExampleAccount,它有自己的 ExampleAccountFactory。该示例账户使用单个签名者来验证 user operation。
为了授权执行某个 user operation,会对 user operation 中除签名本身之外的所有字段生成哈希并进行签名。
validateUserOp 方法定义如下:
这是一个相对简单的签名验证实现,因为它检查了 userOp 的所有字段,以及 entrypoint 地址和 chain id。
作为账户抽象的目标之一,validateUserOp 方法可以包含任意逻辑(尽管受限于存储访问的限制),因为该方法代表了 user operation 起源的条件。
对于这个示例账户,user operation 起始于所有者的签名。但更一般地说,user operation 可以源自任意条件:链上状态、多重签名,或应用特定的签名——这正是账户抽象的一个特性。
为演示这个漏洞,让我们构造恶意的 calldata 传给 EntryPoint.handleOps,使得 EntryPoint 发出的 UserOperationEvent 具有意料之外的值。
在定义了一个示例 UserOperation memory uo 结构体之后,以下是构造 calldata 的方式:
rightPadBytes 是一个辅助函数,用于将 bytes 类型对齐到最近的完整字长度。
其定义如下:
现在,当调用 handleOps 时,发出的事件和 EntryPoint.getUserOpHash\(\) 的结果将会不同。
影响
恶意的 bundler,或调用 EntryPoint.handleOps 的非 bundler EOA,可以修改其在 calldata 中对 UserOp 的表示形式,从而改变发出事件中的 UO hash。这会破坏与所发出事件集成的链下系统,因为这些事件现在被证明对于给定的 UO 并非确定性的。
此外,bundler 在读取从 EntryPoint 合约发出的 userOpHashes 时也将不得不处理非确定性问题。要判断从 EntryPoint 发出的 UserOperationEvent 是否对应 bundler 本地内存池中的某个 user operation,仅比较哈希值已经不够了,因为传给 handleOps 的 calldata 可以被修改以改变哈希值。
相反,bundler 将不得不查找交易回执、获取发送给 handleOps 的 calldata、解码该 calldata,然后通过标准 ABI 编码器重新编码并调用 EntryPoint.getUserOpHash\(...\) 来获得“规范”哈希。这是判断本地内存池中的 user operation 是否已被打包上链所必需的步骤。此外,由于对 EntryPoint.handleOps 的调用可能发生在其他合约调用内部,解码工作可能需要深入调用栈。
这种差异还会影响 bundler RPC 方法的实现,因为在 eth\_getUserOperationByHash 和 eth\_getUserOperationReceipt 中,user op hash 被用作标识。
bundler 将需要对传给 EntryPoint.handleOps\(...\) 的 calldata 进行代价高昂的搜索、解析和解码,以便将事件中发出的哈希转换为来自 EntryPoint.getUserOpHash\(...\) 的“规范”哈希。
💡 注意: 这个漏洞与恶意 SCW 可以重放 user operation 这一事实不同。被重放的 user operation,以及更一般地所有 user operation,都应该具有确定性的哈希。构建于 ERC-4337 之上的其他应用和服务,在此问题解决之前,都需要各自实现自己的缓解措施。
由于 ERC-4337 整体尚处于采用的早期阶段,很难描述该漏洞对更广泛生态系统的潜在影响。影响范围取决于 bundler、user operation 浏览器、索引器以及其他链下服务的具体实现。
在最低限度上,这会造成令人困惑的用户体验,因为 user operation hash(类似于交易哈希)可能在提交和被打包之间发生变化,一些钱包可能没有考虑到这种差异,导致无法向用户展示正确的更新。
在中等风险的情况下,钱包可以被设计为通过将所有 user op hash 设置为相同值来刻意规避索引(参见 @leekt 提供的示例)。
在高风险的情况下,监控 user operation 是否被打包的链下服务可能会漏掉某个 user operation 已被打包的事实,从而尝试重新发送或以其他方式错误处理数据和密钥。
概念验证
完整的概念验证请见这个仓库。
VerifyingPaymaster 打包漏洞
VerifyingPaymaster 引入的风险是:一个 user operation 在签名时与上链时的内容可能不同。这种情况会在两个不同的 user operation 从 VerifyingPaymaster.getHash\(UserOperation userOp, uint48 validUntil, uint48 validAfter\) 返回相同哈希时发生。
让我们为这个函数构造 calldata,来展示这种情况是如何发生的:
Calldata 1
注意这个编码改变了动态字段的顺序,但在其他方面依然有效——偏移量指向正确的位置,长度也都有效。但是,由于 paymasterAndData 的偏移量是一个意料之外的值,我们从 pack\(\) 得到的切片将会是如下内容:
看,initCode 和 callData 都被排除在切片之外!
现在让我们构造第二份输入 calldata,这次恶意地同时修改这两个字段:
initCode将从1517c0de变为1517c0de02callData将从ca11dada1变为ca11data02
这样我们得到如下结果:
Calldata 2
如果对这份不同的 calldata 执行相同的 pack 操作,结果将得到与之前相同的切片!我们可以通过以下方式验证这两个哈希是相同的:
在 foundry 的测试环境中运行这段代码可以发现,两个 user op 具有相同的哈希:0x736f86d224bab46a95ae119947e172efa694379d9ac682d4ca780b7640a89b06
完整的概念验证请见这个测试文件。
影响
在这个漏洞中,哈希可以被修改为覆盖比预期更少的元素,使得 initCode、callData 以及可能的其他静态字段被排除在哈希之外,从而在签名时与使用时产生差异。这可能导致 paymaster 的赞助签名被用于与预期不同的用途。
例如,钱包合约的部署工厂(deployer factory)以及对钱包 execute 函数的调用可以被替换。如果某个 paymaster 之前只想赞助其钱包的用户,并且只在他们铸造特定 NFT 时进行赞助,这些规则就有可能被绕过。
为了绕过这些规则,发送方可以在获得签名后修改 userOp.initCode 和 userOp.callData。这样,paymaster 的原生代币(ETH 或其他)就会被用于他们本意之外的用途,而不是原本预期的无 gas NFT 铸造。
以 ABI 编码格式接收 user operation 进行签名的链下签名者,或者具有合约集成来为签名准备数据的签名者,都容易受到影响。这是一个有限的范围,因为他们本质上是在“自我攻击”,但这确实给运营 paymaster 服务带来了风险。
针对这种情况的防御措施包括部署更新版本的 VerifyingPaymaster,或者自行处理来自用户输入的 ABI 编码过程。
结论
在与生态系统成员进行了多次出色的沟通之后,@drortirosh 合并了一个经过优化、可读性良好的针对 Entrypoint 合约的补丁,以解决这个漏洞。重新部署后,Entrypoint 合约将不再表现出上述行为。
此外,还有一个提案建议在 Entrypoint 中对 nonce 支持进行抽象,这也能强化这个系统。鉴于对 paymaster 的风险有限,目前尚未制定官方的上游补丁,paymaster 运营方可以根据各自实现的需要自行决定如何处理这个问题。
我们在此感谢 4337 社区,包括 @drortirosh、@Gooong、@taylorjdawson、@leekt 和 @livingrockrises,与我们一起解决了这个漏洞。
有任何问题或想讨论的话题吗?
请通过 account-abstraction@alchemy.com 联系我们。
Alchemy 新闻通讯
第一时间获取发布信息
订阅我们的新闻通讯
获取 Alchemy 的最新产品更新和资源
填写您的电子邮箱地址即表示您同意接收我们的营销通讯和产品更新。您确认 Alchemy 按照我们的隐私声明处理我们收到的信息。您可以随时取消订阅。


