ERC-4337 Sample VerifyingPaymaster Signature Replay Attacks
An analysis of signature replay flaws in the ERC-4337 sample VerifyingPaymaster that can drain deposits and bypass transaction sponsorship policies.
Author: taek lee
Auditing at Spearbit
Last week, I was going through eth-infinitism’s account-abstraction contract repository and found some vulnerability in their sample codes. I have contacted eth-infinitism about the vulnerability and approved to share this to public.
TL;DR: Your funds are safe if you are not running VerifyingPaymaster
Summary
Target code: eth-infinitism/account-abstraction
Commit hash: 6dea6d8752f64914dd95d932f673ba0f9ff8e144
Disclaimer
I am a security researcher but I am not a erc4337 expert, need to follow up with researchers for rationale on design choices thus my solutions may conflict with their spec or intentions.
And this report does not mean that smart contract does not contain any other security flaws. I have focused on malicious signature replay events.
Background
ERC4337 contract is composed of 1) EntryPoint, 2) Sender(Account) 3) Paymaster 4) Aggregator
Aggregator is important but i’m skipping this because it is not relevant to the findings.
EntryPoint
EntryPoint is the only component that needs to be trusted. Sender and Paymaster should trust EntryPoint.
EntryPoint is where Bundlers executes transaction. Passed transactions will be unbundled into UserOperation and then it will be verified/executed based on the logic.
Also EntryPoint is expected to be deployed only once but can be deployed multiple times if there are needs for the upgrade.
Sender
Sender contract is an account, and it is fully controlled by user. It has to be upgradeable, and should not be able to execute any UserOperation without approval of Sender’s owner(user).
Since it is upgradeable, Sender is expected to deployed with proxy and have one implementation contract.
Paymaster
Paymaster is additional component used to pay for Sender’s gas fee.
Since Paymaster’s assets are used to pay for the gas, Paymaster should verify if the UserOperation is what they approved.
Verify methods can be anything including, 1) erc20 token based payments 2) nft based verify 3) Paymaster’s signature based verify.
This article is mainly about third method.
Findings
VerifyingPaymaster’s signature can be replayed to drain their deposits
Attacker - Sender
Victim - VerifyingPaymaster
function getHash(UserOperation calldata userOp)
public pure returns (bytes32) {
//can't use userOp.hash(), since it contains also the paymasterAndData itself.
return keccak256(abi.encode(
userOp.getSender(),
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.callGasLimit,
userOp.verificationGasLimit,
userOp.preVerificationGas,
userOp.maxFeePerGas,
userOp.maxPriorityFeePerGas
));
}
/**
* verify our external signer signed this request.
* the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params
*/
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external view override returns (bytes memory context, uint256 sigTimeRange) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
bytes calldata paymasterAndData = userOp.paymasterAndData;
uint256 sigLength = paymasterAndData.length - 20;
//ECDSA library supports both 64 and 65-byte long signatures.
// we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA"
require(sigLength == 64 || sigLength == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData");
//don't revert on signature failure: return SIG_VALIDATION_FAILED
if (verifyingSigner != hash.toEthSignedMessageHash().recover(paymasterAndData[20 :])) {
return ("",1);
}
validatePaymasterUserOP() checks if userOp’s hash has been signed by verifyingSigner or not. But as you can see, it does not checks if same userOp has been already executed or not.
Attack will not happen if Sender is not malicious since Sender itself check if userOp has been executed before to prevent the replay attack.
But, if Sender get’s malicious, it can drain VerifyingPaymaster’s deposit whenever it wants
Scenario:
- user A is happy with paymaster and behaves well.
- Paymaster grants gas for userOp(op1) and generate signature –sig X
- A becomes not happy with paymaster and wants to attack paymaster
- A upgrades it’s Sender contract to MaliciousAccount contract which does not need nonce increase on
userOpvalidation. Instead, it has simplerequire(tx.origin == owner)constraint to protect itself from signature replay attack. Also does not executes anything with userOp. - A uses sig X(the one that used before) to execute the same op1 over and over
- since Paymaster does not check if UserOp was executed before, Paymaster will payout the gas fee on everytime.
- user A may earn nothing but it will drain Paymaster’s deposit
Paymaster’s deposit can be drained with multichain setup(and also for cross contract replays)
Attacker - Sender
Victim - VerifyingPaymaster
function getHash(UserOperation calldata userOp)
public pure returns (bytes32) {
//can't use userOp.hash(), since it contains also the paymasterAndData itself.
return keccak256(abi.encode(
userOp.getSender(),
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.callGasLimit,
userOp.verificationGasLimit,
userOp.preVerificationGas,
userOp.maxFeePerGas,
userOp.maxPriorityFeePerGas
));
}
/**
* verify our external signer signed this request.
* the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params
*/
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external view override returns (bytes memory context, uint256 sigTimeRange) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
bytes calldata paymasterAndData = userOp.paymasterAndData;
uint256 sigLength = paymasterAndData.length - 20;
//ECDSA library supports both 64 and 65-byte long signatures.
// we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA"
require(sigLength == 64 || sigLength == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData");
//don't revert on signature failure: return SIG_VALIDATION_FAILED
if (verifyingSigner != hash.toEthSignedMessageHash().recover(paymasterAndData[20 :])) {
return ("",1);
}
Since ERC4337 does not limit themselves to Ethereum, it is easy to predict they will be on every EVM-related network.
But, as you can see in validatePaymasterUserOp() and getHash(), signature does not considers the chainid. Which makes signature replaying attack quite easily if Sender’s address is same for other networks since they will accept same signature on both side.
Also, signature does not consider the address of the paymaster itself. Which means signature can also be replayed if there are more than one VerifyingPaymaster.
User can bypass the sponsored tx policy and can possibly drain paymaster’s deposit with transaction withhold attack
Attacker - Sender
Victim - VerifyingPaymaster
function getHash(UserOperation calldata userOp)
public pure returns (bytes32) {
//can't use userOp.hash(), since it contains also the paymasterAndData itself.
return keccak256(abi.encode(
userOp.getSender(),
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.callGasLimit,
userOp.verificationGasLimit,
userOp.preVerificationGas,
userOp.maxFeePerGas,
userOp.maxPriorityFeePerGas
));
}
/**
* verify our external signer signed this request.
* the "paymasterAndData" is expected to be the paymaster and a signature over the entire request params
*/
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external view override returns (bytes memory context, uint256 sigTimeRange) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
bytes calldata paymasterAndData = userOp.paymasterAndData;
uint256 sigLength = paymasterAndData.length - 20;
//ECDSA library supports both 64 and 65-byte long signatures.
// we only "require" it here so that the revert reason on invalid signature will be of "VerifyingPaymaster", and not "ECDSA"
require(sigLength == 64 || sigLength == 65, "VerifyingPaymaster: invalid signature length in paymasterAndData");
//don't revert on signature failure: return SIG_VALIDATION_FAILED
if (verifyingSigner != hash.toEthSignedMessageHash().recover(paymasterAndData[20 :])) {
return ("",1);
}
This one depends on the policy and business logic.
Let’s say your policy for paying the gas is “max 1 eth per day per account” or maybe “only one tx per account”. And you want to solve it through VerifyingPaymaster.
If attacker wants to bypass this policy, and want to use 2 eth or 2 tx for oneday.
Attack scenario will be,
- Attacker sends it’s userOp(op1) to paymaster.
- paymaster signs op1
- Attacker does not broadcasts op1 and holds it
- paymaster indicates this as error or timeout
- Attacker sends one more userOp(op2) to paymaster
- paymaster signs op2
- Attacker broadcasts both op1 and op2 => policy bypass successful
Solution
To solve all issues, this is the patch i suggest
mapping(address => uint256) public senderNonce;
function getHash(UserOperation calldata userOp)
public view returns (bytes32) {
//can't use userOp.hash(), since it contains also the paymasterAndData itself.
address sender = userOp.getSender();
return keccak256(abi.encode(
sender,
userOp.nonce,
keccak256(userOp.initCode),
keccak256(userOp.callData),
userOp.callGasLimit,
userOp.verificationGasLimit,
userOp.preVerificationGas,
userOp.maxFeePerGas,
userOp.maxPriorityFeePerGas,
userOp.paymasterAndData[:SIGNATURE_OFFSET],
block.chainid,
senderNonce[sender]
));
}
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external override returns (bytes memory context, uint256 sigTimeRange) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
senderNonce[userOp.getSender()]++;
Changes are
- change validatePaymasterUserOp to non-view function
- add senderNonce storage variable and increase it everytime there is validation request.
- add block.chainid, senderNonce, userOp.paymasterAndData[:SIGNATURE_OFFSET] to encode
Adding block.chainid multichain and cross contract signature replay.
SenderNonce will block withhold attack and signature replay attack using malicious sender.
Lastly, it is a design that needs to be confirmed but i added userOp.paymasterAndData[:SIGNATURE_OFFSET] in encode.
userOp.paymasterAndData is designed to have paymaster address in it’s first 20 bytes. And I found out that it needs 16 more bytes for updated erc 4337 version.
Which is for validUntil(8bytes) and validAfter(8bytes) for signature validity time range.
So i assumed that this should be packed in to the paymasterAndData which makes signature’s offset in paymasterAndData become 36(20 + 8 + 8).
And because we cannot have signature inside the encode, only first 36 part of paymasterAndData should be included in the encode.
Lesson Learned
-
Please use EIP 712 for on-chain signature verification
most of the issues above can be mitigated with EIP 712
-
If you are running VerifyingPaymaster for ERC 4337 accounts, do not trust users.
if you assume they will behave like you intended, it will result in big loss
Future plans
- Will be contributing the eth-infinitism repo for following weeks to fix issues above.
- Will be writing series of writing safe erc4337 components articles
- Contact known erc4337 account providers to update their code
- DMs open. If you want me to review your implementation, or any help needed, ping me through twitter/discord/telegram
Contact
twitter: @leekt216
github: @leekt
telegram: @leekt
discord: taek#7585