← Back to Blog

ERC-4337 Account Abstraction Security: 6 Vulnerabilities in Bundlers, Paymasters, and Smart Wallets

2026-04-18 erc-4337 account-abstraction smart-wallet paymaster bundler solidity-security signature-replay gas-griefing

ERC-4337 brings account abstraction to Ethereum without a protocol-level hard fork, enabling smart contract wallets to pay gas in ERC-20 tokens, batch transactions, and support social recovery. The spec is elegant, but the attack surface is substantial. The key actors — EntryPoint, wallets, paymasters, and bundlers — each introduce unique trust boundaries that auditors and developers must examine carefully.

This post walks through six concrete vulnerabilities seen in ERC-4337 deployments, with vulnerable Solidity code, an explanation of the flaw, a corrected version, and detection tips for each.


1. Signature Replay Across Chains

Vulnerable Code

// VULNERABLE: signature hash does not include chainId
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    bytes32 hash = keccak256(abi.encode(
        userOp.sender,
        userOp.nonce,
        userOp.callData
    ));
    address signer = ECDSA.recover(hash.toEthSignedMessageHash(), userOp.signature);
    if (signer != owner) return SIG_VALIDATION_FAILED;
    _payPrefund(missingAccountFunds);
    return 0;
}

Explanation

The wallet constructs its own hash from userOp.sender, userOp.nonce, and userOp.callData — omitting chainId. A valid signature produced on Ethereum mainnet is therefore structurally identical to one on Arbitrum, Optimism, or any other EVM chain where the same wallet address is deployed. An attacker who observes a signed UserOperation on one chain can replay it verbatim on every other chain where the wallet exists.

The ERC-4337 spec provides userOpHash — a hash that already includes chainId, entryPoint, and the full UserOperation — precisely to prevent this. Constructing a secondary, chain-agnostic hash defeats the protection the spec offers.

Fixed Code

// FIXED: use the spec-provided userOpHash which includes chainId and entryPoint
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    // userOpHash is computed by EntryPoint as:
    // keccak256(abi.encode(keccak256(userOp), address(entryPoint), block.chainid))
    address signer = ECDSA.recover(userOpHash.toEthSignedMessageHash(), userOp.signature);
    if (signer != owner) return SIG_VALIDATION_FAILED;
    _payPrefund(missingAccountFunds);
    return 0;
}

Detection Tips

Search for any hash construction inside validateUserOp that does not reference userOpHash directly. If a wallet builds its own digest, check whether block.chainid and address(this) are both included. Fuzz with the same UserOperation on simulated forks of two different chains — if both validate, the vulnerability is present.


2. Paymaster Gas Griefing via Inflated postOp

Vulnerable Code

// VULNERABLE: postOp has no gas cap; malicious userOp can exhaust paymaster deposit
function postOp(
    PostOpMode mode,
    bytes calldata context,
    uint256 actualGasCost
) external override {
    (address user, uint256 allowance) = abi.decode(context, (address, uint256));
    // Expensive storage writes controlled by userOp callData
    for (uint256 i = 0; i < allowance; i++) {
        usageLog[user][i] = actualGasCost;
    }
    _chargeUser(user, actualGasCost);
}

Explanation

postOp is called by the EntryPoint after a UserOperation executes and it is funded from the paymaster's staked deposit, not the user's account. If postOp performs work proportional to user-supplied data (here, allowance from context), a malicious user can craft a UserOperation whose context encodes a large allowance value, causing the loop to run thousands of iterations and consuming far more gas than the operation is worth. Because the paymaster committed its deposit before knowing the true postOp cost, the attacker drains the paymaster's stake at no cost to themselves.

Fixed Code

// FIXED: cap loop iterations and validate context fields at validation time
uint256 constant MAX_LOG_ENTRIES = 10;

function validatePaymasterUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
) external returns (bytes memory context, uint256 validationData) {
    (address user, uint256 allowance) = abi.decode(userOp.paymasterAndData[20:], (address, uint256));
    require(allowance <= MAX_LOG_ENTRIES, "allowance too large");
    return (abi.encode(user, allowance), 0);
}

function postOp(
    PostOpMode mode,
    bytes calldata context,
    uint256 actualGasCost
) external override {
    (address user, uint256 allowance) = abi.decode(context, (address, uint256));
    // allowance was already bounded to MAX_LOG_ENTRIES above
    for (uint256 i = 0; i < allowance; i++) {
        usageLog[user][i] = actualGasCost;
    }
    _chargeUser(user, actualGasCost);
}

Detection Tips

Audit every loop, external call, and SSTORE inside postOp. Trace the source of any iteration count or unbounded input back to context or userOp. Use gas profiling to compare the maximum postOp cost against the paymaster's per-operation collateral. Any scenario where postOp gas exceeds the declared verificationGasLimit is a griefing vector.


3. Missing Nonce Validation in validateUserOp

Vulnerable Code

// VULNERABLE: nonce is never checked; old operations can be replayed
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    address signer = ECDSA.recover(userOpHash.toEthSignedMessageHash(), userOp.signature);
    if (signer != owner) return SIG_VALIDATION_FAILED;
    _payPrefund(missingAccountFunds);
    return 0;  // nonce check omitted entirely
}

Explanation

ERC-4337 wallets are responsible for maintaining their own nonce state. The EntryPoint passes userOp.nonce to validateUserOp, but it is up to the wallet to reject operations whose nonce does not match the expected sequence. If the wallet skips this check, any previously executed — and already-signed — UserOperation can be replayed. The attacker simply resubmits it through any bundler. Funds can be transferred again, approvals re-issued, or arbitrary calls replicated without a new owner signature.

Fixed Code

// FIXED: enforce sequential nonce
mapping(address => uint256) public nonces;

function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    address signer = ECDSA.recover(userOpHash.toEthSignedMessageHash(), userOp.signature);
    if (signer != owner) return SIG_VALIDATION_FAILED;
    require(userOp.nonce == nonces[userOp.sender], "invalid nonce");
    nonces[userOp.sender]++;
    _payPrefund(missingAccountFunds);
    return 0;
}

Detection Tips

Search for validateUserOp implementations that never reference userOp.nonce in a comparison or require. Write a test that executes a UserOperation twice with the same nonce and assert the second reverts. Confirm that nonces[sender] is incremented exactly once per successful validation and that the increment is non-reentrant.


4. Paymaster Allowlist Bypass (TOCTOU)

Vulnerable Code

// VULNERABLE: token approval checked at validation time, transfer executed later
function validatePaymasterUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
) external returns (bytes memory context, uint256 validationData) {
    address token = address(bytes20(userOp.paymasterAndData[20:40]));
    uint256 price = oracle.getPrice(token);
    uint256 tokenCost = (maxCost * price) / 1e18;
    // Check approval at validation time
    require(
        IERC20(token).allowance(userOp.sender, address(this)) >= tokenCost,
        "insufficient allowance"
    );
    return (abi.encode(userOp.sender, token, tokenCost), 0);
}

function postOp(
    PostOpMode mode,
    bytes calldata context,
    uint256 actualGasCost
) external override {
    (address user, address token, uint256 tokenCost) = abi.decode(context, (address, address, uint256));
    // Transfer happens here — after callData already executed
    IERC20(token).transferFrom(user, address(this), tokenCost);
}

Explanation

This is a classic time-of-check / time-of-use (TOCTOU) race. The paymaster validates token allowance during validatePaymasterUserOp, but the actual transferFrom does not occur until postOp. The UserOperation's callData executes in between. A malicious user can encode a callData that calls token.approve(paymaster, 0) — revoking the allowance mid-flight. By the time postOp runs, the allowance is zero and the paymaster either reverts (failing to collect fees) or catches the error and absorbs the gas cost, effectively giving the user a free transaction.

Fixed Code

// FIXED: transfer tokens upfront during validation, refund excess in postOp
function validatePaymasterUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
) external returns (bytes memory context, uint256 validationData) {
    address token = address(bytes20(userOp.paymasterAndData[20:40]));
    uint256 price = oracle.getPrice(token);
    uint256 maxTokenCost = (maxCost * price) / 1e18;
    // Pull tokens immediately — state change before callData runs
    IERC20(token).transferFrom(userOp.sender, address(this), maxTokenCost);
    return (abi.encode(userOp.sender, token, maxTokenCost, price), 0);
}

function postOp(
    PostOpMode mode,
    bytes calldata context,
    uint256 actualGasCost
) external override {
    (address user, address token, uint256 maxTokenCost, uint256 price) =
        abi.decode(context, (address, address, uint256, uint256));
    uint256 actualTokenCost = (actualGasCost * price) / 1e18;
    if (maxTokenCost > actualTokenCost) {
        IERC20(token).transfer(user, maxTokenCost - actualTokenCost);
    }
}

Detection Tips

Look for any paymaster that reads allowance or balanceOf in validatePaymasterUserOp but defers transferFrom to postOp. Model the execution order — validation, then callData, then postOp — and ask whether user-controlled callData can invalidate any state assumption made during validation.


5. EntryPoint Trust Assumption Without Target Whitelist

Vulnerable Code

// VULNERABLE: any callData is executed when msg.sender == entryPoint
function execute(address dest, uint256 value, bytes calldata data) external {
    require(msg.sender == entryPoint || msg.sender == owner, "not authorized");
    (bool success, bytes memory result) = dest.call{value: value}(data);
    if (!success) {
        assembly { revert(add(result, 32), mload(result)) }
    }
}

Explanation

Trusting msg.sender == entryPoint is correct for authentication but insufficient for authorization. The EntryPoint faithfully relays whatever callData the UserOperation specifies. If the wallet does not restrict which dest addresses it will call, a compromised or malicious UserOperation — one that passes signature validation with a valid owner signature — can instruct the wallet to call any contract: a token contract to drain funds, a protocol to take out a loan against the wallet's collateral, or the wallet itself to self-destruct. Whitelisting at the EntryPoint level is not a substitute for checking inside the wallet.

Fixed Code

// FIXED: maintain a target whitelist and validate dest before executing
mapping(address => bool) public allowedTargets;

function execute(address dest, uint256 value, bytes calldata data) external {
    require(msg.sender == entryPoint || msg.sender == owner, "not authorized");
    require(allowedTargets[dest] || msg.sender == owner, "dest not whitelisted");
    (bool success, bytes memory result) = dest.call{value: value}(data);
    if (!success) {
        assembly { revert(add(result, 32), mload(result)) }
    }
}

function setAllowedTarget(address target, bool allowed) external {
    require(msg.sender == owner, "only owner");
    allowedTargets[target] = allowed;
}

Detection Tips

Check every execution path reachable when msg.sender == entryPoint. If dest is taken from userOp.callData without further validation, the wallet imposes no restriction beyond signature correctness. Test by constructing a UserOperation whose callData targets an address that should be unreachable (e.g., a token the owner never approved) and verify it reverts.


6. initCode Front-Running via CREATE2 Address Collision

Vulnerable Code

// VULNERABLE: factory does not bind salt to deployer identity
contract WalletFactory {
    function createWallet(bytes32 salt, address owner) external returns (address wallet) {
        wallet = address(new SimpleWallet{salt: salt}(owner));
        emit WalletCreated(wallet, owner);
    }
}

Explanation

ERC-4337 uses initCode to deploy a wallet at a deterministic address computed with CREATE2 before the first UserOperation executes. The address depends on the factory address, the salt, and the creation bytecode. If the salt does not encode the intended owner or deployer, an attacker who observes the pending UserOperation in the mempool can submit a separate transaction calling createWallet with the same salt but a different owner. Because CREATE2 is deterministic, the attacker's transaction lands at the identical address — but with the attacker's owner key installed. The legitimate UserOperation then fails because a contract already exists at that address, or worse succeeds against a wallet the attacker controls.

Fixed Code

// FIXED: derive salt from the intended owner to prevent front-running
contract WalletFactory {
    function createWallet(address owner) external returns (address wallet) {
        // Salt binds to owner: same owner always gets the same address, different owner cannot collide
        bytes32 salt = keccak256(abi.encodePacked(owner));
        wallet = address(new SimpleWallet{salt: salt}(owner));
        require(SimpleWallet(payable(wallet)).owner() == owner, "owner mismatch");
        emit WalletCreated(wallet, owner);
    }
}

Detection Tips

Review every factory's CREATE2 salt construction. If the salt is caller-supplied without binding to msg.sender or the intended owner, any address can race to consume it. Simulate a front-run in tests: deploy from a different address with the same salt and verify the second deployer cannot take control of the resulting wallet.


Conclusion

ERC-4337 distributes trust across a new stack — EntryPoint, wallet, paymaster, bundler, and factory — and every handoff is a potential attack boundary. Signature replay exploits missing chain scope; gas griefing exploits unbounded postOp work; nonce omissions enable replay; TOCTOU flaws let users revoke state between validation and execution; blind EntryPoint trust hands over arbitrary call authority; and weak CREATE2 salts let attackers hijack wallet addresses before they are used.

If you are building or auditing an ERC-4337 wallet or paymaster, scan your contracts at contractscan.io — ContractScan's static and semantic analysis detects all six vulnerability classes discussed here, including cross-chain signature scope, unbounded gas paths in postOp, and CREATE2 salt binding issues, before they reach mainnet.


Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →