Account Abstraction: Security for Auditors
Account abstraction is quite a new topic. Even many top auditors I've seen in our space are still learning about all the nuances Account Abstraction brings, including some new security risks.
Below I've detailed a few different issues I've found while trying to learn more about the security risks related to Account Abstraction security. If you're an auditor hopefully this can be used as a learning tool for your next ERC4337 audit.
ValidatePaymasterUserOp should check for address(this)
When implementing a paymaster you will need to create a userOp hash to be validated by the Paymaster itself.
You must include values such as the paymasterID
and the chain.id
to avoid reply attacks. If these values aren't included in the preimage hash
a signature can be replayed to another paymaster using the same faulty implementation.
Consider the code below where a hash is being computed to the UserOperation which will be sent to the validatePaymasterOp
function to be validated by the paymaster.
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
));
}
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external view override returns (bytes memory context, uint256 deadline) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
PaymasterData memory paymasterData = userOp.decodePaymasterData();
uint256 sigLength = paymasterData.signatureLength;
//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");
require(verifyingSigner == hash.toEthSignedMessageHash().recover(paymasterData.signature), "VerifyingPaymaster: wrong signature");
require(requiredPreFund <= paymasterIdBalances[paymasterData.paymasterId], "Insufficient balance for paymaster id");
return (userOp.paymasterContext(paymasterData), 0);
}
The getHash
function derives the hash of UserOp specifically for the paymaster's internal usage. The paymaster will then verify the signer of the signature is the equal to the verifyingSigner
or in this case the msg.sender
. As you can see address(this)
should be hashed in the preimage as well so that this signature cannot be replayed with another paymaster.
function getHash(UserOperation calldata userOp, uint48 validUntil, uint48 validAfter)
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,
block.chainid,
address(this),
validUntil,
validAfter
)
);
}
The validateUserOp
should always return SIG_VALIDATION_FAILED
: Not following EIP standard
When validating signatures, the validateUserOp
function must always return SIG_VALIDATION_FAILED
if signature validation fails. You can read more about it in the EIP here.
"If the account does not support signature aggregation, it MUST validate the signature is a valid signature of the userOpHash, and SHOULD return SIG_VALIDATION_FAILED
(and not revert) on signature mismatch. Any other error should revert."
Below is a code snippet from a recent contest on code4rena.
try IAccount(sender).validateUserOp{gas : mUserOp.verificationGasLimit}(op, opInfo.userOpHash, aggregator, missingAccountFunds) returns (uint256 _deadline) {
// solhint-disable-next-line not-rely-on-time
if (_deadline != 0 && _deadline < block.timestamp) {
revert FailedOp(opIndex, address(0), "AA22 expired");
}
deadline = _deadline;
} catch Error(string memory revertReason) {
revert FailedOp(opIndex, address(0), revertReason);
} catch {
revert FailedOp(opIndex, address(0), "AA23 reverted (or OOG)");
}
You can see from the code above that if validateUserOp()
fails it reverts everytime even when if the recovered signature does not match. This goes against the EIP specs and led to a Medium severity finding in this contest.
Below you can see an example of how the validateUserOp
should be implemented based on the original EIP specs.
function _validateSignature(UserOperation calldata userOp, bytes32 userOpHash)
internal override virtual returns (uint256 validationData) {
bytes32 hash = userOpHash.toEthSignedMessageHash();
if (owner != hash.recover(userOp.signature))
return SIG_VALIDATION_FAILED;
return 0;
}
All bridged funds will be lost for the users using the account abstraction wallet
This was one of the more interesting issues I found related to AA. This isn't necessarily an issue with implementation as much as it is an issue for protocols, specifically cross-chain ones, that interact with AA accounts. I suspect we'll start to see a lot more of these kinds of issues popping up. Knowing about them now can be very lucrative.
The idea here is that if you are building a cross-chain EVM bridge or something similar, we assume a user's EVM address on the current chain will be the same on the destination chain. With ERC4337 that is not the case, users with account abstraction wallets will have different addresses on different chains.
In this specific issue, we see the payload pass msg.sender
as the receiving address on the destination chain assuming that the user has the same address. However, this is not the case for account abstraction.
bytes memory payload = abi.encode(VERSION, msg.sender, amount, nonce++);
The following function calls the callContract
function that passes the payload to the Axelar network.
Then on the destination, an Axelar node will call the execute()
function passing in the payload and minting the tokens to the account abstraction wallet address of the source chain but on the destination, the same person will not be the owner of that address leaving the tokens permanently lost.
function _execute(
string calldata srcChain,
string calldata srcAddr,
bytes calldata payload
) internal override whenNotPaused {
(bytes32 version, address srcSender, uint256 amt, uint256 nonce) = abi
.decode(payload, (bytes32, address, uint256, uint256));
bytes32 txnHash = keccak256(payload);
txnHashToTransaction[txnHash] = Transaction(srcSender, amt);
_attachThreshold(amt, txnHash, srcChain);
_approve(txnHash);
_mintIfThresholdMet(txnHash);
emit MessageReceived(srcChain, srcSender, amt, nonce);
}
To mitigate against this attack allow the user to pass in the address as a parameter to the function. Some wallets may follow the deterministic deployment approach to have the same address, but you cannot rely on this as each chain has its own state and opcode differences so even a deterministic approach may generate different addresses.
Paymaster ETH can be drained with a malicious sender
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external view override returns (bytes memory context, uint256 deadline) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
PaymasterData memory paymasterData = userOp.decodePaymasterData();
uint256 sigLength = paymasterData.signatureLength;
//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");
require(verifyingSigner == hash.toEthSignedMessageHash().recover(paymasterData.signature), "VerifyingPaymaster: wrong signature");
require(requiredPreFund <= paymasterIdBalances[paymasterData.paymasterId], "Insufficient balance for paymaster id");
return (userOp.paymasterContext(paymasterData), 0);
}
Scenario :
User A gets transaction sponsored by paymaster which has a sig of X
User A decides they now want to attack the paymaster
user A uses sig X(the one that used before) to initiate the same tx over and over
user A earned nearly nothing but paymaster will get their deposits drained
Add a nonce to the paymaster signature validation in order to prevent the replay of signatures.
function validatePaymasterUserOp(UserOperation calldata userOp, bytes32 /*userOpHash*/, uint256 requiredPreFund)
external view override returns (bytes memory context, uint256 deadline) {
(requiredPreFund);
bytes32 hash = getHash(userOp);
PaymasterData memory paymasterData = userOp.decodePaymasterData();
uint256 sigLength = paymasterData.signatureLength;
//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");
// Check the nonce
require(nonces[userOp.user] == userOp.nonce, "Invalid nonce");
require(verifyingSigner == hash.toEthSignedMessageHash().recover(paymasterData.signature), "VerifyingPaymaster: wrong signature");
require(requiredPreFund <= paymasterIdBalances[paymasterData.paymasterId], "Insufficient balance for paymaster id");
// Increment the nonce for the user
nonces[userOp.user]++;
return (userOp.paymasterContext(paymasterData), 0);
}
Subscribe to my newsletter
Read articles from 33Audits directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by