Breaking ERC-4337 Infrastructure with UserOpHash

Manipulating ABI offsets to make different UserOperations produce the same userOpHash.

I found that ERC-4337’s hash function did not always generate a consistent userOpHash. By manipulating ABI offsets in userOp calldata, I could hide data from the hash. I eventually built an unaudited wallet that generated the same userOpHash for different UserOperations.

I first noticed the problem in issues submitted by @candidewallet and @AlchemyPlatform: eth-infinitism/account-abstraction#237.

The packing problem

The issue was related to userOp.pack(). It uses assembly to pack userOp into bytes, but it needs to remove the signature. That makes it hard to pack in one line with abi.encodePacked(userOp), so it uses userOp[0:userOp.signature.offset].

In ideal cases, this is fine because the offsets are generated in the order of the struct parameters. It becomes strange when someone deliberately tries to break that assumption.

My first idea was to hide userOp.callData from the signature. This is one of the risks users face when blind-signing on phishing sites that put signature.offset above callData.offset.

It is the wallet’s responsibility to verify the UserOperation properly, so a wallet can handle this by checking the order of the parameter offsets. I wanted to go further and make a wallet that could hide all data from userOpHash.

Experiment screenshot

Using the nonce slot as the signature offset

I used the nonce slot in calldata as the signature offset. To explain the encoding, split the bytes of userOp into 32-byte slots. Each slot represents a field in the userOp struct.

UserOperation slots

Encoding a dynamic parameter requires an offset, a length, and a value. The offset points to where the length and data begin, and the length specifies how much data to read. Reading initCode from calldata therefore means reading slot 2 for the offset, slot 11 for the length, and then the next 32 bytes.

Dynamic parameter encoding

Because the bytes used by userOp.pack() are [0:signature.offset], setting signature.offset to 1 * 32 makes userOpHash depend only on the wallet address.

I set signature.offset to 1 * 32, which made the nonce act as signature.length. Because the nonce was 14 * 32, the signature value covered slots 2 through 15. With this encoding, userOp.pack() used only the address when packing the data.

Signature offset layout

Preserving signature checks

On its own, this design would be vulnerable to signature replay. Because slots 2 through 15 are fully accessible as userOp.signature, however, I could use only part of that data. I used slots 13, 14, and 15 as the signature’s r, s, and v. It is also possible to add more to the nonce as long as there is enough callData, or to pack zeros.

Signature slots

This allowed any nonce larger than 448 while still using the signature in a way that was safe from known attacks.

I ended up with a wallet that was safe from signature replay and generated the same userOpHash repeatedly: TestSender.sol.

Warning: This wallet has not been audited. Do not use it in production.

Infrastructure impact

I sent two transactions, each containing two UserOperations with different calldata and nonces. They emitted the same userOpHash: D61829949ECA3BE869443AD39D5000331F5FE5F8020FBD022A392D06D435D0A6.

This broke Blocknative’s UserOperation dashboard and JiffyScan, and Stackup’s RPC did not respond. I am not blaming those teams; I am sharing the debugging scenario to make infrastructure builders aware of the issue.