---
title: "ERC-4337 UserOperation 打包漏洞"
description: "2023 年 3 月 7 日，Alchemy 与开源社区成员发现 ERC-4337 EntryPoint 合约及示例 VerifyingPaymaster 合约存在 calldata 解码问题。"
---

# ERC-4337 UserOperation 打包漏洞

**其他贡献者**：
[fangting@alchemy.com](mailto:fangting@alchemy.com)、@drortirosh、@Gooong、@taylorjdawson、@leekt、@livingrockrises

## 概述

2023 年 3 月 7 日，Alchemy 及开源开发者社区的其他成员，包括 [@Gooong](https://github.com/Gooong)、[@taylorjdawson](https://github.com/taylorjdawson)、[@leekt](https://github.com/leekt) 和 [@livingrockrises](https://github.com/livingrockrises)，发现了 ERC-4337 EntryPoint 合约以及示例 VerifyingPaymaster 合约存在 calldata 解码问题。

这些合约目前已部署到多条链上，用于对 user operations 生成哈希。该实现导致哈希值随签名方式的不同而不一致，这可能带来若干二阶影响，例如同一个 UserOperation 产生不同哈希、不同的 UserOperation 产生相同哈希。

讨论由 [@drortirosh ](https://github.com/drortirosh)推动，并记录在[这个 Github issue](https://github.com/eth-infinitism/account-abstraction/issues/237#issuecomment-1466686252) 中。

## 详细分析

以下是对受影响代码的分析，包括 EntryPoint 打包漏洞、VerifyingPaymaster 打包漏洞的说明，以及它们各自的影响。

### 受影响的代码

涉及的代码段如下：

#### ‍[UserOperation.sol:61-75](https://github.com/eth-infinitism/account-abstraction/blob/556f03fadcaba0d7d04cd901e6eb9601db50a998/contracts/interfaces/UserOperation.sol#L61-l75)

#### [VerifyingPaymaster.sol:35-49](https://github.com/eth-infinitism/account-abstraction/blob/556f03fadcaba0d7d04cd901e6eb9601db50a998/contracts/samples/VerifyingPaymaster.sol#L35-l49)

作为背景说明，`UserOperation` 结构体的定义如下：

这两段代码都使用汇编来将 calldata 的一大部分复制到内存中，目的是捕获要哈希的 user operation 的部分内容。

`UserOperationLib` 中的 `pack` 方法旨在捕获 user operation 从 `sender` 到 `maxPriorityFeePerGas` 的所有字段，包括可变长度字段（在 ABI 编码中称为动态字段）`initCode`、`callData` 和 `paymasterAndData`。

`VerifyingPaymaster` 中的 `pack` 方法包含除 `paymasterAndData` 字段外的所有这些字段，因为该字段尚未定义。

为实现这一点，两个方法都使用了 [Yul](https://docs.soliditylang.org/en/v0.8.17/yul.html) 为 calldata 中的动态类型提供的一个便利字段，名为 `.offset`。这指的是结构体 ABI 编码中提供的值，定义见[此处 Solidity 规范](https://docs.soliditylang.org/en/v0.8.18/abi-spec.html#formal-specification-of-the-encoding)。**（它实际上指的是偏移量之后的那个内存字，但这只是为了便于加载。）**

标准的 ABI 编码器会按照字段出现的顺序对动态字段的值进行编码（在 ABI 编码器中称为它们的 tail）。

考虑以下可能生成的 user operation 在 calldata 中的编码：

<CalloutBlock>

💡 **注意：** 为简洁起见，此示例展示的 user operation 中所有动态字段长度均小于一个字。

</CalloutBlock>

<CalloutBlock>

💡‍ **\*‍**\***注意：** 此处的内存地址空间是在 user operation 结构体内部。在实际的 calldata 中，由于方法选择器和参数元组占用的空间，它会被放置在其他位置。

</CalloutBlock>

在此示例中，按照 `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\(...\)` 的“规范”哈希。

<CalloutBlock>

💡 **注意：** 这个漏洞与恶意 SCW 可以重放 user operation 这一事实不同。被重放的 user operation，以及更一般地所有 user operation，都应该具有确定性的哈希。构建于 ERC-4337 之上的其他应用和服务，在此问题解决之前，都需要各自实现自己的缓解措施。

</CalloutBlock>

由于 ERC-4337 整体尚处于采用的早期阶段，很难描述该漏洞对更广泛生态系统的潜在影响。影响范围取决于 bundler、user operation 浏览器、索引器以及其他链下服务的具体实现。

在**最低限度**上，这会造成令人困惑的用户体验，因为 user operation hash（类似于交易哈希）可能在提交和被打包之间发生变化，一些钱包可能没有考虑到这种差异，导致无法向用户展示正确的更新。

在**中等风险**的情况下，钱包可以被设计为通过将所有 user op hash 设置为相同值来刻意规避索引（参见 [@leekt 提供的示例](https://github.com/leekt/foundry-4337/compare/PoC/sameUserOpHash)）。

在**高风险**的情况下，监控 user operation 是否被打包的链下服务可能会漏掉某个 user operation 已被打包的事实，从而尝试重新发送或以其他方式错误处理数据和密钥。

#### 概念验证

完整的概念验证请见[这个仓库](https://github.com/alchemyplatform/entrypoint-hash-poc)。

### VerifyingPaymaster 打包漏洞

`VerifyingPaymaster` 引入的风险是：一个 user operation 在签名时与上链时的内容可能不同。这种情况会在两个不同的 user operation 从 `VerifyingPaymaster.getHash\(UserOperation userOp, uint48 validUntil, uint48 validAfter\)` 返回相同哈希时发生。

让我们为这个函数构造 calldata，来展示这种情况是如何发生的：

#### Calldata 1
注意这个编码改变了动态字段的顺序，但在其他方面依然有效——偏移量指向正确的位置，长度也都有效。但是，由于 `paymasterAndData` 的偏移量是一个意料之外的值，我们从 `pack\(\)` 得到的切片将会是如下内容：

看，`initCode` 和 `callData` 都被排除在切片之外！

现在让我们构造第二份输入 calldata，这次恶意地同时修改这两个字段：

- `initCode` 将从 `1517c0de` 变为 `1517c0de02`
- `callData` 将从 `ca11dada1` 变为 `ca11data02`

这样我们得到如下结果：

#### Calldata 2
如果对这份不同的 calldata 执行相同的 `pack` 操作，结果将得到与之前相同的切片！我们可以通过以下方式验证这两个哈希是相同的：

在 foundry 的测试环境中运行这段代码可以发现，两个 user op 具有相同的哈希：`0x736f86d224bab46a95ae119947e172efa694379d9ac682d4ca780b7640a89b06`

完整的概念验证请见[这个测试文件](https://github.com/alchemyplatform/entrypoint-hash-poc/blob/main/test/VerifyingPaymasterHashPOC.t.sol)。

#### 影响

在这个漏洞中，哈希可以被修改为覆盖比预期更少的元素，使得 initCode、callData 以及可能的其他静态字段被排除在哈希之外，从而在签名时与使用时产生差异。这可能导致 paymaster 的赞助签名被用于与预期不同的用途。

例如，钱包合约的部署工厂（deployer factory）以及对钱包 `execute` 函数的调用可以被替换。如果某个 paymaster 之前只想赞助其钱包的用户，并且只在他们铸造特定 NFT 时进行赞助，这些规则就有可能被绕过。

为了绕过这些规则，发送方可以在获得签名后修改 `userOp.initCode` 和 `userOp.callData`。这样，paymaster 的原生代币（ETH 或其他）就会被用于他们本意之外的用途，而不是原本预期的无 gas NFT 铸造。

以 ABI 编码格式接收 user operation 进行签名的链下签名者，或者具有合约集成来为签名准备数据的签名者，都容易受到影响。这是一个有限的范围，因为他们本质上是在“自我攻击”，但这确实给运营 paymaster 服务带来了风险。

针对这种情况的防御措施包括部署更新版本的 `VerifyingPaymaster`，或者自行处理来自用户输入的 ABI 编码过程。

## 结论

在与生态系统成员进行了多次出色的沟通之后，**@drortirosh** 合并了一个经过优化、可读性良好的[针对 Entrypoint 合约的补丁](https://github.com/eth-infinitism/account-abstraction/commit/6a7cbe7c6c786d6302d52b94405b88c6596f95cb)，以解决这个漏洞。重新部署后，Entrypoint 合约将不再表现出上述行为。

此外，还有一个[提案](https://docs.google.com/document/d/1MywdH_TCkyEjD3QusLZ_kUZg4ZEI00qp97mBze9JI4k/edit#heading=h.gyhqxhuyd59n)建议在 Entrypoint 中对 nonce 支持进行抽象，这也能强化这个系统。鉴于对 paymaster 的风险有限，目前尚未制定官方的上游补丁，paymaster 运营方可以根据各自实现的需要自行决定如何处理这个问题。

我们在此感谢 4337 社区，包括 **@drortirosh**、**@Gooong**、**@taylorjdawson**、**@leekt** 和 **@livingrockrises**，与我们一起解决了这个漏洞。

有任何问题或想讨论的话题吗？

请通过 account-abstraction@alchemy.com 联系我们。
