0%
HomeBlogTechnical
ERC-4337 UserOperation Packing Vulnerability

ERC-4337 UserOperation Packing Vulnerability

Author: Adam Egyed

Reviewed by Brady Werkheiser


Published on March 21, 20236 min read

Additional Contributors:
fangting@alchemy.com, @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.

Discussion was facilitated by @drortirosh and is documented in this Github issue.

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:

Copied
function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) {         //lighter signature scheme. must match UserOp.ts#packUserOp         bytes calldata sig = userOp.signature;         // copy directly the userOp from calldata up to (but not including) the signature.         // this encoding depends on the ABI encoding of calldata, but is much lighter to copy         // than referencing each field separately.         assembly {             let ofs := userOp             let len := sub(sub(sig.offset, ofs), 32)             ret := mload(0x40)             mstore(0x40, add(ret, add(len, 32)))             mstore(ret, len)             calldatacopy(add(ret, 32), ofs, len)         }     }
Copied
function pack(UserOperation calldata userOp) internal pure returns (bytes memory ret) {         // lighter signature scheme. must match UserOp.ts#packUserOp         bytes calldata pnd = userOp.paymasterAndData;         // copy directly the userOp from calldata up to (but not including) the paymasterAndData.         // this encoding depends on the ABI encoding of calldata, but is much lighter to copy         // than referencing each field separately.         assembly {             let ofs := userOp             let len := sub(sub(pnd.offset, ofs), 32)             ret := mload(0x40)             mstore(0x40, add(ret, add(len, 32)))             mstore(ret, len)             calldatacopy(add(ret, 32), ofs, len)         }     }

For context, the UserOperation struct is defined as:

Copied
struct UserOperation { address sender; uint256 nonce; bytes initCode; bytes callData; uint256 callGasLimit; uint256 verificationGasLimit; uint256 preVerificationGas; uint256 maxFeePerGas; uint256 maxPriorityFeePerGas; bytes paymasterAndData; bytes signature; }

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.

Copied
@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 sender @0x020: 0000000000000000000000000000000000000000000000000000000000000007 nonce @0x040: 0000000000000000000000000000000000000000000000000000000000000160 offset of initCode @0x060: 00000000000000000000000000000000000000000000000000000000000001a0 offset of callData @0x080: 000000000000000000000000000000000000000000000000000000000000ab12 callGasLimit @0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 verificationGasLimit @0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef preVerificationGas @0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 maxFeePerGas @0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 maxPriorityFeePerGas @0x120: 00000000000000000000000000000000000000000000000000000000000001e0 offset of paymasterAndData @0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of signature @0x160: 0000000000000000000000000000000000000000000000000000000000000004 length of initCode @0x180: 1517c0de00000000000000000000000000000000000000000000000000000000 initCode @0x1a0: 0000000000000000000000000000000000000000000000000000000000000004 length of callData @0x1c0: ca11dada00000000000000000000000000000000000000000000000000000000 callData @0x1e0: 0000000000000000000000000000000000000000000000000000000000000003 length of paymasterAndData @0x200: de12ad0000000000000000000000000000000000000000000000000000000000 paymasterAndData @0x220: 0000000000000000000000000000000000000000000000000000000000000006 length of signature @0x240: dedede1234560000000000000000000000000000000000000000000000000000 signature

💡‍ 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:

Copied
@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 sender @0x020: 0000000000000000000000000000000000000000000000000000000000000007 nonce @0x040: 0000000000000000000000000000000000000000000000000000000000000160 offset of initCode @0x060: 00000000000000000000000000000000000000000000000000000000000001a0 offset of callData @0x080: 000000000000000000000000000000000000000000000000000000000000ab12 callGasLimit @0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 verificationGasLimit @0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef preVerificationGas @0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 maxFeePerGas @0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 maxPriorityFeePerGas @0x120: 00000000000000000000000000000000000000000000000000000000000001e0 offset of paymasterAndData @0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of signature @0x160: 0000000000000000000000000000000000000000000000000000000000000004 length of initCode @0x180: 1517c0de00000000000000000000000000000000000000000000000000000000 initCode @0x1a0: 0000000000000000000000000000000000000000000000000000000000000004 length of callData @0x1c0: ca11dada00000000000000000000000000000000000000000000000000000000 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.

EntryPoint Packing Vulnerability

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:

Copied
_requireFromEntryPoint(); bytes32 hash = keccak256(   abi.encode(     userOp.sender,     userOp.nonce,     userOp.initCode,     userOp.callData,     userOp.callGasLimit,     userOp.verificationGasLimit,     userOp.preVerificationGas,     userOp.maxFeePerGas,     userOp.maxPriorityFeePerGas,     userOp.paymasterAndData,     address(_entryPoint),     block.chainid   ) ).toEthSignedMessageHash(); if (owner != hash.recover(userOp.signature)) {   return SIG_VALIDATION_FAILED; } _validateAndUpdateNonce(userOp); _payPrefund(missingAccountFunds); return 0;

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:

Copied
bytes memory callData = abi.encodePacked(   entryPoint.handleOps.selector,   uint256(0x40), // Offset of ops   uint256(uint160(account)), // beneficiary   uint256(1), // Len of ops   uint256(0x20), // offset of ops[0]   uint256(uint160(uo.sender)),   uo.nonce,   uint256(0x240), // offset of uo.initCode (encoding assumes a 65-byte long signature, which it is using the provided address.)   uint256(0x180), // offset of uo.callData   uo.callGasLimit,   uo.verificationGasLimit,   uo.preVerificationGas,   uo.maxFeePerGas,   uo.maxPriorityFeePerGas,   uint256(0x160), // offset of uo.paymasterAndData   uint256(0x1c0), // offset of uo.signature   uint256(uo.paymasterAndData.length),   rightPadBytes(uo.paymasterAndData),   uint256(uo.callData.length),   rightPadBytes(uo.callData),   uint256(uo.signature.length),   rightPadBytes(uo.signature),   uint256(uo.initCode.length),   rightPadBytes(uo.initCode)   );

rightPadBytes is a helper function written to align bytes types to the nearest full word length.

It is defined as follows:

Copied
function rightPadBytes(bytes memory input) internal pure returns (bytes memory) {   bytes memory zeroPadding = "";   uint256 zeros = 32 - (input.length % 32);   if (zeros != 32) {     for (uint256 i = 0; i < zeros; ++i) {     zeroPadding = bytes.concat(zeroPadding, hex"00");     }   } return bytes.concat(input, zeroPadding); }

Now, when calling handleOps, the emitted event and the result of EntryPoint.getUserOpHash() will be different.

Copied
address(entryPoint).call(callData);

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:

Copied
args@0x000:                0000000000000000000000000000000000000000000000000000000000000060 head(args[0]) = where tail(args[0]) starts within args args@0x020:                0000000000000000000000000000000000000000000000000000000000000020 validUntil args@0x040:                0000000000000000000000000000000000000000000000000000000000000020 validAfter args@0x060: args[0]@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 uo.sender args@0x080: args[0]@0x020: 0000000000000000000000000000000000000000000000000000000000000007 uo.nonce args@0x0a0: args[0]@0x040: 00000000000000000000000000000000000000000000000000000000000001a0 offset of uo.initCode within args[0] args@0x0c0: args[0]@0x060: 00000000000000000000000000000000000000000000000000000000000001e0 offset of uo.callData within args[0] args@0x0e0: args[0]@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 uo.callGasLimit args@0x100: args[0]@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 uo.verificationGasLimit args@0x120: args[0]@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef uo.preVerificationGas args@0x140: args[0]@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 uo.maxFeePerGas args@0x160: args[0]@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 uo.maxPriorityFeePerGas args@0x180: args[0]@0x120: 0000000000000000000000000000000000000000000000000000000000000160 offset of uo.paymasterAndData within args[0] args@0x1a0: args[0]@0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of uo.signature within args[0] args@0x1c0: args[0]@0x160: 0000000000000000000000000000000000000000000000000000000000000003 length of paymasterAndData args@0x1e0: args[0]@0x180: de12ad0000000000000000000000000000000000000000000000000000000000 paymasterAndData args@0x200: args[0]@0x1a0: 0000000000000000000000000000000000000000000000000000000000000004 length of initCode args@0x220: args[0]@0x1c0: 1517c0de00000000000000000000000000000000000000000000000000000000 initCode args@0x240: args[0]@0x1e0: 0000000000000000000000000000000000000000000000000000000000000004 length of callData args@0x260: args[0]@0x200: ca11dada00000000000000000000000000000000000000000000000000000000 callData itself args@0x280: args[0]@0x220: 0000000000000000000000000000000000000000000000000000000000000006 length of signature args@0x2a0: args[0]@0x240: dedede1234560000000000000000000000000000000000000000000000000000 signature itself

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:

Copied
args@0x060: args[0]@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 uo.sender args@0x080: args[0]@0x020: 0000000000000000000000000000000000000000000000000000000000000007 uo.nonce args@0x0a0: args[0]@0x040: 00000000000000000000000000000000000000000000000000000000000001a0 offset of uo.initCode within args[0] args@0x0c0: args[0]@0x060: 00000000000000000000000000000000000000000000000000000000000001e0 offset of uo.callData within args[0] args@0x0e0: args[0]@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 uo.callGasLimit args@0x100: args[0]@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 uo.verificationGasLimit args@0x120: args[0]@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef uo.preVerificationGas args@0x140: args[0]@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 uo.maxFeePerGas args@0x160: args[0]@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 uo.maxPriorityFeePerGas args@0x180: args[0]@0x120: 0000000000000000000000000000000000000000000000000000000000000160 offset of uo.paymasterAndData within args[0] args@0x1a0: args[0]@0x140: 0000000000000000000000000000000000000000000000000000000000000220 offset of uo.signature within args[0]

See how initCode and callData are excluded from the slice!

Let’s construct a second input calldata, this time maliciously modifying both fields:

  • initCode will go from 1517c0de to 1517c0de02

  • callData will go from ca11dada1 to ca11data02

This gives us the following:

Copied
args@0x000:                0000000000000000000000000000000000000000000000000000000000000060 head(args[0]) = where tail(args[0]) starts within args args@0x020:                0000000000000000000000000000000000000000000000000000000000000020  validUntil args@0x040:                0000000000000000000000000000000000000000000000000000000000000020  validAfter args@0x060: args[0]@0x000: 0000000000000000000000009a7908627581072a5be468464c32ac8bf2239466 uo.sender args@0x080: args[0]@0x020: 0000000000000000000000000000000000000000000000000000000000000007 uo.nonce args@0x0a0: args[0]@0x040: 00000000000000000000000000000000000000000000000000000000000001a0 where uo.initCode starts within args[0] args@0x0c0: args[0]@0x060: 00000000000000000000000000000000000000000000000000000000000001e0 where uo.callData starts within args[0] args@0x0e0: args[0]@0x080: 000000000000000000000000000000000000000000000000000000000000ab12 uo.callGasLimit args@0x100: args[0]@0x0a0: 000000000000000000000000000000000000000000000000000000000000de34 uo.verificationGasLimit args@0x120: args[0]@0x0c0: 00000000000000000000000000000000000000000000000000000000000000ef uo.preVerificationGas args@0x140: args[0]@0x0e0: 00000000000000000000000000000000000000000000000000000002540be400 uo.maxFeePerGas args@0x160: args[0]@0x100: 000000000000000000000000000000000000000000000000000000003b9aca00 uo.maxPriorityFeePerGas args@0x180: args[0]@0x120: 0000000000000000000000000000000000000000000000000000000000000160 where uo.paymasterAndData starts within args[0] args@0x1a0: args[0]@0x140: 0000000000000000000000000000000000000000000000000000000000000220 where uo.signature starts within args[0] args@0x1c0: args[0]@0x160: 0000000000000000000000000000000000000000000000000000000000000003 length of paymasterAndData args@0x1e0: args[0]@0x180: de12ad0000000000000000000000000000000000000000000000000000000000 paymasterAndData itself args@0x200: args[0]@0x1a0: 0000000000000000000000000000000000000000000000000000000000000005 length of initCode args@0x220: args[0]@0x1c0: 1517c0de02000000000000000000000000000000000000000000000000000000 initCode itself args@0x240: args[0]@0x1e0: 0000000000000000000000000000000000000000000000000000000000000005 length of callData args@0x260: args[0]@0x200: ca11dada02000000000000000000000000000000000000000000000000000000 callData itself args@0x280: args[0]@0x220: 0000000000000000000000000000000000000000000000000000000000000006 length of signature args@0x2a0: args[0]@0x240: dedede1234560000000000000000000000000000000000000000000000000000 signature itself

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:

Copied
(, bytes memory uoHash1) = address(verifyingPaymaster).call(abi.encodePacked(verifyingPaymaster.getHash.selector, calldata1)); (, bytes memory uoHash2) = address(verifyingPaymaster).call(abi.encodePacked(verifyingPaymaster.getHash.selector, calldata2)); assertEq(uoHash1, uoHash2);

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 account-abstraction@alchemy.com.

Section background image

Build blockchain magic

Alchemy combines the most powerful web3 developer products and tools with resources, community and legendary support.

Get your API key