Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form
[email protected], @drortirosh, @Gooong, @taylorjdawson, @leekt, @livingrockrises
On March 7th, 2023, Alchemy and other members of the open source developer community, including @Gooong, @taylorjdawson, @leekt, and @livingrockrises, identified calldata decoding issues with the ERC-4337 EntryPoint contract and the example VerifyingPaymaster contract.
These contracts are currently deployed to several chains and generate hashes over user operations. The implementation resulted in inconsistent hashes depending on the signing method, which can lead to several second order effects like divergent hashes for the same UserOperations and colliding hashes for differing UserOperations.
Below is a breakdown of the affected code, explanations of the EntryPoint Packing Vulnerability, the VerifyingPaymaster Packing Vulnerability, and their respective impact.
The code segment in question is the following:
For context, the UserOperation struct is defined as:
Both of these code segments use assembly to copy a large portion of the calldata into memory, intending to capture part of a user operation to hash.
The pack method in UserOperationLib intends to capture all fields of the user operation from sender to maxPriorityFeePerGas, including the variable-size fields (called dynamic fields in ABI encoding) initCode, callData, and paymasterAndData.
The pack method in VerifyingPaymaster includes all of those fields except the paymasterAndData field, since that is not yet defined.
To implement this, both methods use a convenience field in Yul provided to dynamic types in calldata, named .offset. This refers to the value provided in the ABI-encoding of a struct, which is defined here in the Solidity spec. (It actually refers to the memory word after the offset, but that’s just for convenience when loading).
A standard ABI-encoder will encode the values for dynamic fields (called their tail in the ABI coder) in the order which they appear.
Consider the following encoding of a user operation in calldata that might be generated:
Note: This example shows a user operation where all dynamic fields are less than one word in length for brevity.
Note: The memory address space here is within the user operation struct itself. In actual calldata, it will be placed elsewhere due to space occupied by method selecter and the arguments tuple.
In this example, following pnd.offset to generate a packing of the user operation will result in this “slice” of calldata:
This contains exactly what we want!
However, contracts that use ABI-encoded arguments do not validate what order fields are defined in, or even that the offsets are valid.
Using signature.offset or pnd.offset will read the corresponding “offset” value directly from calldata.
By using that as a boundary, it is possible to construct valid representations of user operations in calldata that have unusual hash properties.
Let’s explore how this affects the EntryPoint and VerifyingPaymaster independently.
To demonstrate this vulnerability, we must consider a wallet contract that is different from the provided SimpleAccount.sol, because that sample re-uses the vulnerable code from EntryPoint.
The hash divergence becomes material when a different hashing scheme is used between the EntryPoint and the wallet contract, or if the wallet signs a non-standard user operation encoding.
This risk introduced to EntryPoint are that a single user operation can be represented by multiple “user op hashes” and that the same “user op hash” can represent multiple user operations.
Consider this account, called ExampleAccount, that has it’s own ExampleAccountFactory. The example account uses a single signer to validate user operations.
To grant permission to run a user operation, a hash over all fields in the user operation, except the signature itself, is generated and signed.
The validateUserOp method is defined as follows:
This is a relatively simple implementation of signature validation, as it checks all fields of userOp, along with the entrypoint address and the chain id.
As one of the goals of account abstraction, the validateUserOp method can contain arbitrary logic (though bounded by limitations to storage access), since this method represents the conditions under which a user operation can originate.
For this example account, user operations start from a signature by the owner. More generally, however, user operations can originate from arbitrary conditions: on-chain state, multiple signatures, or app-specific signatures – it’s a feature of account abstraction.
To demonstrate this vulnerability, let’s construct malicious calldata to EntryPoint.handleOps such that the UserOperationEvent emitted by EntryPoint will have an unexpected value.
After defining a sample UserOperation memory uo struct, here is how we can construct the calldata:
rightPadBytes is a helper function written to align bytes types to the nearest full word length.
It is defined as follows:
Now, when calling handleOps, the emitted event and the result of EntryPoint.getUserOpHash() will be different.
Malicious bundlers, or non-bundler EOAs calling EntryPoint.handleOps, can modify their representation of a UserOp in calldata to change the UO hash in emitted events. This can break off-chain systems integrating with the emitted events, since the events are now revealed to be non-deterministic for a given UO.
Additionally, the bundler will have to deal with non-determinism when reading emitted userOpHashes from the EntryPoint contract. To see if an emitted UserOperationEvent from the EntryPoint corresponds to a user operation in the bundler’s local mempool, a comparison of the hash value is no longer enough, as the calldata to handleOps can be modified to change hash values.
Instead, bundlers will have to look up transaction receipts, fetch the calldata sent to handleOps, decode the calldata, then get the “canonical” hashes by re-encoding via a the standard ABI coder and calling EntryPoint.getUserOpHash(...). This is needed to determine whether or not user operations in the local mempool have been mined. Additionally, since calls to EntryPoint.handleOps can happen from within other contract calls, the decoding can be deep in the call stack.
This divergence will also affect the implementation of bundler RPC methods, as a user op hash is used for identification in eth_getUserOperationByHash and eth_getUserOperationReceipt.
Bundlers will need to perform expensive searches, parsing, and decoding of calldata to EntryPoint.handleOps(...) to translate the emitted hashes from events into “canonical” hashes from EntryPoint.getUserOpHash(...).
Note: This vulnerability is distinct from the fact that rogue SCWs can reuse user operations. Reused user operations, and more generally, all user operations, should have a deterministic hash. Other applications and services that build on top of ERC-4337 will have to implement their own mitigation unless this is resolved.
Since ERC-4337 is at the early stages of adoption overall, it is hard to describe the potential impact of this vulnerability on the broader ecosystem. The scope of impact depends on the implementations of bundlers, user operation explorers, indexers, and other offchain services.
At a minimum, it would cause a confusing user experience, as the user operation hash (similar to the transaction hash) can change between submission and inclusion time, so some wallets might not account for that difference and fail to display updates to their users.
In a medium risk case, wallets can be designed such that they intentionally avoid indexing by setting all of their user op hashes to be the same (see the example of this provided by @leekt).
In a high risk case, an offchain service monitoring user op inclusion could miss the inclusion of a given user operation, and attempt to resend or otherwise mishandle data and keys.
See the full proof of concept in this repo.
The risks introduced to VerifyingPaymaster are that a user operation may contain different contents between signing time and inclusion on-chain. This can happen when two different user operations return the same hash from VerifyingPaymaster.getHash(UserOperation userOp, uint48 validUntil, uint48 validAfter).
Let’s construct calldata for this function to show how this can be the case:
Note how this encoding changes the order of the dynamic fields, but is otherwise still valid - offsets point to the correct locations and lengths are all valid. But, because the offset of paymasterAndData is an unexpected value, the slice we get from pack() will be the following:
See how initCode and callData are excluded from the slice!
Let’s construct a second input calldata, this time maliciously modifying both fields:
This gives us the following:
If we perform the same pack operation on this different calldata, it will result in the same slice as before! And we can verify that the hashes are the same with the following:
Running this in a test environment in foundry reveals that both user ops have the same hash: 0x736f86d224bab46a95ae119947e172efa694379d9ac682d4ca780b7640a89b06
See this test file for the full POC.
In this vulnerability, the hash can be modified to cover fewer elements than expected, allowing for initCode, callData, and possibly other static fields to be excluded from the hash, and thus vary between signing and usage. This can result in paymaster sponsorship signatures being used for different purposes than intended.
For instance, the wallet contract’s deployer factory and the call to a wallet’s execute function can be substituted. If a paymaster previously wanted to only sponsor users of their wallet, and only sponsor when they mint a specific NFT, those rules could be bypassed.
To bypass the rules, the sender would change userOp.initCode and userOp.callData after getting a signature. Then, the paymaster’s native token (ETH or otherwise) will be used for some other purpose than their intention of a gasless NFT mint.
Offchain signers which receive user operations to sign in an ABI-encoded format, or signers that have contract integrations to prepare data for signature, are vulnerable. This is a limited scope, as they are essentially “exploiting themselves”, but it presents a risk to operating a paymaster service.
Defensive measures against this include deploying an updated version of VerifyingPaymaster, or handling the process of ABI encoding themselves from user input.
After several excellent conversations with ecosystem members, @drortirosh merged an optimized, readable patch to the Entrypoint contract to address this vulnerability. Once redeployed, the Entrypoint contracts will no longer be exhibiting the behavior documented above.
Additionally, there is a proposal to abstract nonce support in the Entrypoint that hardens this system as well. Given the risk to paymasters is limited, no official upstream patch has been made and paymaster operators can decide how to handle this as needed for their implementations.
We want to thank the 4337 community here, including @drortirosh, @Gooong, @taylorjdawson, @leekt, and @livingrockrises for working through this vulnerability with us.
Have any questions or topics you want to discuss?
Reach us at [email protected].