## Brief I found this bug for Ethereum Foundation's Account Abstraction. It is now fixed in the latest [version 0.8.0](https://github.com/eth-infinitism/account-abstraction/releases/tag/v0.8.0). When users are tricked into using malicious pay masters, they can lose money. The protocol is now secure. I am glad to contribute to its security. ![Screenshot_2025-04-16_15-36-42](https://hackmd.io/_uploads/B1PmCA3CJg.png) ## Story If you have an ERC-4337 smart account, sometimes your tx gas is "sponsored" by a pay master. How? Find a pay master that is willing to pay for your tx gas, let the pay master sign the tx, and you sign the tx. Then, you don't need to pay gas anymore! Now imagine, a guy come to you and say: "I am willing to pay your tx gas. Give me your tx and I will sign it." You think, wonderful! Go ahead and pay for my gas! Uh oh, the gas price is high now, and the tx is not getting through. Let's set a very high gas price, because you are not paying it anyways. Right? You send the new tx (with a higher gas price) to the pay master again, and they are willing to sign it. Nice! The tx went through! We did not pay for the gas! Oh wait, after checking the account balance again, you notice a loss of a lot of money! How did that happen? ## How Good thing you are not a blockchain noob. You know how to figure this out. You go to etherscan, and look up your tx. Where did your funds go? It says it went to the original caller of the tx, in ERC-4337 terms, a bundler. **Why did the bundler get your money? What is its role in this?** For normal EOA initiated tx, you submit your own tx to the mempool and get picked up by block builder (miner, etc.). ERC-4337 designed it differently. Basically there is a extra layer of "block builder" called bundlers, and a separate mempool. You don't submit the tx and therefore don't directly pay for the gas. Instead, you sign a tx, send that to the separate mempool, and let bundlers submit it to the mempool. After that it is all the same, the block builders and stuff. Bundlers will get compensated for the gas and make a profit. ![image](https://hackmd.io/_uploads/B1l0476C1g.png) Source: https://gaiax-blockchain.com/what-is-the-aa-and-erc4337 OK, that seems fair. The bundlers spend some gas, and get compensated with some gas. Maybe it is not the fault of the bundler. We are barking at the whole tree. Wait, you then do some OSINT, and confirm that the bundler and the pay master signer are controlled by the same guy! You are convinced that you are attacked. They trick you into doing this, and steal your money! But still, this is not the root cause of the problem. **What is the root cause?** You sign for a high gas price, and pay a high gas price. The question is, why does the pay master not pay for the price? The only reason you signed a high gas price, is that there is a pay master! How is it possible that the pay master signed, and not pay for the price? Well, technically, this should not happen. The one who makes sure of this mechanism is an on-chain protocol called "Entry Point". Let's call it EP. It is an official protocol to handle all ERC-4337 tx. If a pay master signs for this tx, then pay master must pay for the gas. Otherwise, the smart account itself will pay for the price. Seems fair. You checked your tx again, and verified that there is a properly signed `paymasterAndData` field, then **how did the pay master not pay**? It is time to dig deeper. You go to your high tech blockchain lab, and run some experiments to mimic what happened. You forked the chain locally and reverted back to the tx when it happened. Your eyes are wide open to catch whatever is fishy going on there. After some logging and debugging, you find that the EP doesn't think there is a pay master somehow, so the user pays for the price. ``` function _validateAccountPrepayment( uint256 opIndex, PackedUserOperation calldata op, UserOpInfo memory opInfo, uint256 requiredPrefund, uint256 verificationGasLimit ) internal returns ( uint256 validationData ) { unchecked { MemoryUserOp memory mUserOp = opInfo.mUserOp; address sender = mUserOp.sender; _createSenderIfNeeded(opIndex, opInfo, op.initCode); address paymaster = mUserOp.paymaster; uint256 missingAccountFunds = 0; if (paymaster == address(0)) { uint256 bal = balanceOf(sender); missingAccountFunds = bal > requiredPrefund ? 0 : requiredPrefund - bal; } try IAccount(sender).validateUserOp{ gas: verificationGasLimit }(op, opInfo.userOpHash, missingAccountFunds) returns (uint256 _validationData) { validationData = _validationData; } catch { revert FailedOpWithRevert(opIndex, "AA23 reverted", Exec.getReturnData(REVERT_REASON_MAX_LEN)); } if (paymaster == address(0)) { /* user pays */ DepositInfo storage senderInfo = deposits[sender]; uint256 deposit = senderInfo.deposit; if (requiredPrefund > deposit) { revert FailedOp(opIndex, "AA21 didn't pay prefund"); } senderInfo.deposit = deposit - requiredPrefund; } } } ``` Now let's trace back, why does the EP not think there is a pay master? You confirm that it goes to `paymasterAndData.length > 0` branch. Wait, the unpacked pay master address can be 0, which is unexpected here. For all following code, it sees pay master is 0, and think there is no pay master. ``` function _copyUserOpToMemory( PackedUserOperation calldata userOp, MemoryUserOp memory mUserOp ) internal pure { mUserOp.sender = userOp.sender; mUserOp.nonce = userOp.nonce; (mUserOp.verificationGasLimit, mUserOp.callGasLimit) = UserOperationLib.unpackUints(userOp.accountGasLimits); mUserOp.preVerificationGas = userOp.preVerificationGas; (mUserOp.maxPriorityFeePerGas, mUserOp.maxFeePerGas) = UserOperationLib.unpackUints(userOp.gasFees); bytes calldata paymasterAndData = userOp.paymasterAndData; if (paymasterAndData.length > 0) { require( paymasterAndData.length >= UserOperationLib.PAYMASTER_DATA_OFFSET, "AA93 invalid paymasterAndData" ); (mUserOp.paymaster, mUserOp.paymasterVerificationGasLimit, mUserOp.paymasterPostOpGasLimit) = UserOperationLib.unpackPaymasterStaticFields(paymasterAndData); } else { mUserOp.paymaster = address(0); /* when there is no pay master */ mUserOp.paymasterVerificationGasLimit = 0; mUserOp.paymasterPostOpGasLimit = 0; } } ``` ## Fix When unpacking the pay master data, do not allow it to be 0. There is a lesson learned. As a user, be more technical, and don't trust weird things. Especially what gives you money for no reason. As a developer or a white hat, always consider the edge cases.