ERC-4337 UserOperation Packing Vulnerability
Additional Contributors:
[email protected], @drortirosh, @Gooong, @taylorjdawson, @leekt, @livingrockrises
Overview
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.
Detailed Breakdown
Below is a breakdown of the affected code, explanations of the EntryPoint Packing Vulnerability, the VerifyingPaymaster Packing Vulnerability, and their respective impact.
Affected Code
The code segment in question is the following:
UserOperation.sol:61-75
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)
}
}
VerifyingPaymaster.sol:35-49
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:
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.
@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:
@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:
_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:
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:
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.
address(entryPoint).call(callData);
Impact
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.
Proof of Concept
See the full proof of concept in this repo.
VerifyingPaymaster Packing Vulnerability
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:
calldata 1
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:
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 from1517c0de
to1517c0de02
callData
will go fromca11dada1
toca11data02
This gives us the following:
calldata 2
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:
(, 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.
Impact
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.
Conclusion
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].
Related articles
Preparing for the Agave 2.0 Upgrade
ERC-1271 Signature Replay Vulnerability
On October 27th 2023, Alchemy discovered a ERC1271 contract signature replay vulnerability that affected a large number of smart contract accounts (SCA), and led to risks when interacting with several applications.
What is RIP-7212? Precompile for secp256r1 Curve Support
RIP-7212 is a core change in the Ethereum protocol that opens up a way to have cheap, secure, and fast P256 curve verification with a precompiled contract.