DEV Community

ohmygod
ohmygod

Posted on

The ERC-4337 Attack Surface: 6 Exploitable Trust Gaps in Account Abstraction — And How to Close Them

Account abstraction was supposed to make Ethereum wallets smarter and safer. Instead, ERC-4337's multi-component architecture — wallets, bundlers, paymasters, and the EntryPoint contract — has introduced an entirely new class of cross-layer vulnerabilities that cost protocols over $120M in Q1 2026 alone. Here's what's going wrong, and how to defend against it.


Why ERC-4337 Creates New Attack Surfaces

Traditional EOA transactions are simple: sign, broadcast, execute. ERC-4337 replaces this with a pipeline:

User → UserOperation → Bundler → EntryPoint → Wallet (validateUserOp) → Execution
                                      ↓
                                  Paymaster (sponsorship)
Enter fullscreen mode Exit fullscreen mode

Each handoff is a trust boundary. Each trust boundary is an attack surface. The problem isn't any single component — it's that no component has full visibility into the entire pipeline, and attackers exploit the gaps between them.

Attack Vector #1: Signature Replay via Incomplete Domain Separation

The bug: Multiple wallet implementations in early 2026 used incomplete EIP-712 domain separators in their validateUserOp functions. Specifically, they omitted the chainId or verifyingContract fields, enabling cross-chain and cross-contract replay attacks.

The exploit pattern:

// VULNERABLE: Missing chainId in domain separator
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256("EIP712Domain(string name,string version)"),
    keccak256("MyWallet"),
    keccak256("1")
));

// An attacker captures a valid UserOp on Ethereum mainnet
// and replays it on Arbitrum, Optimism, or Base
Enter fullscreen mode Exit fullscreen mode

The fix:

// SECURE: Full EIP-712 domain separator
bytes32 DOMAIN_SEPARATOR = keccak256(abi.encode(
    keccak256(
        "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
    ),
    keccak256("MyWallet"),
    keccak256("1"),
    block.chainid,      // Chain-specific
    address(this)        // Contract-specific
));
Enter fullscreen mode Exit fullscreen mode

Key lesson: Always include chainId AND verifyingContract in your domain separator. Test replay scenarios across every chain you deploy to. Use block.chainid (not a hardcoded value) to handle chain forks automatically.

Attack Vector #2: Gas Griefing Through Bundler Manipulation

The bug: The ERC-4337 EntryPoint validates UserOperations in two phases — validateUserOp (pre-execution) and the actual execution call. An attacker can craft UserOps that pass validation but deliberately revert during execution, wasting the bundler's gas.

Why it matters more than you think: Bundlers front the gas costs. If griefing is cheap enough, attackers can make bundling unprofitable, effectively DoS-ing the entire account abstraction infrastructure for a chain.

The attack:

1. Attacker submits UserOp with valid signature
2. validateUserOp succeeds → bundler includes it in bundle
3. Execution phase reads external state that attacker
   modified between simulation and on-chain execution
4. Execution reverts → bundler pays gas, gets nothing
5. Repeat at scale
Enter fullscreen mode Exit fullscreen mode

Defense patterns for bundlers:

  • Opcode banning during simulation: Reject UserOps that use TIMESTAMP, BLOCKHASH, COINBASE, or read from contracts not explicitly staked with the EntryPoint
  • Reputation tracking: Rate-limit accounts and paymasters that produce reverted UserOps
  • Stake-based throttling: Require paymasters and factories to deposit stake that gets slashed on repeated failures
  • Private mempool submission: Don't broadcast bundles to the public mempool — submit directly to block builders via MEV-protected channels

Attack Vector #3: Paymaster Drain via Unbounded Sponsorship

The bug: Paymasters that sponsor gas for UserOperations often implement insufficient spending limits. An attacker creates thousands of smart contract wallets and submits maximum-gas UserOps, all sponsored by the same paymaster.

Real-world pattern from Q1 2026:

// VULNERABLE: No per-user or per-period limits
function validatePaymasterUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
) external returns (bytes memory context, uint256 validationData) {
    // Only checks: is the user whitelisted?
    require(whitelist[userOp.sender], "Not whitelisted");
    return (abi.encode(userOp.sender), 0);
    // No check on: how much has this user spent today?
    // No check on: is maxCost reasonable?
    // No check on: total paymaster exposure
}
Enter fullscreen mode Exit fullscreen mode

The fix — implement layered spending controls:

function validatePaymasterUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
) external returns (bytes memory context, uint256 validationData) {
    require(whitelist[userOp.sender], "Not whitelisted");

    // Layer 1: Per-operation cap
    require(maxCost <= MAX_COST_PER_OP, "Op too expensive");

    // Layer 2: Per-user daily limit
    uint256 today = block.timestamp / 1 days;
    require(
        dailySpend[userOp.sender][today] + maxCost <= DAILY_LIMIT_PER_USER,
        "Daily limit exceeded"
    );

    // Layer 3: Global paymaster budget
    require(
        totalDailySpend[today] + maxCost <= GLOBAL_DAILY_LIMIT,
        "Paymaster budget exhausted"
    );

    dailySpend[userOp.sender][today] += maxCost;
    totalDailySpend[today] += maxCost;

    return (abi.encode(userOp.sender, maxCost), 0);
}
Enter fullscreen mode Exit fullscreen mode

Attack Vector #4: Reentrancy in handleOps

The bug: The EntryPoint's handleOps function processes multiple UserOperations in a single transaction. If one UserOp's execution triggers a callback that interacts with another UserOp's wallet state, the second UserOp may execute against stale assumptions.

This is cross-UserOp reentrancy — a variant that traditional reentrancy guards don't catch because the re-entry happens across different accounts within the same handleOps batch.

The defense:

// In your wallet's execute function:
modifier crossOpGuard() {
    // Check: are we being called within a handleOps batch
    // where our state may have been read by another UserOp?
    require(
        !IEntryPoint(entryPoint).isExecuting() ||
        _executionNonce == _validatedNonce,
        "Cross-op reentrancy detected"
    );
    _;
}
Enter fullscreen mode Exit fullscreen mode

Bundler-side mitigation: Never include two UserOps in the same bundle if they touch overlapping storage slots. Use storage access tracing during simulation to detect conflicts.

Attack Vector #5: Malicious Bundler Payload Injection

The bug: Users trust bundlers to faithfully include their UserOperations in handleOps calls. But a compromised bundler can:

  1. Modify calldata encoding — The UserOp hash is computed from the packed representation, but Solidity's ABI encoding has edge cases where the same logical UserOp can be encoded differently, producing different hashes in events
  2. Sandwich UserOps — Front-run the bundle with state-changing transactions that make the UserOp execute against manipulated prices
  3. Selectively censor — Drop UserOps that would liquidate the bundler's own positions

Defense pattern — verify bundle integrity on-chain:

// Wallet-side: verify the UserOp hash matches what you signed
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external override returns (uint256 validationData) {
    // Recompute hash from UserOp fields — don't trust the provided hash
    bytes32 computedHash = keccak256(abi.encode(
        userOp.sender,
        userOp.nonce,
        keccak256(userOp.initCode),
        keccak256(userOp.callData),
        userOp.callGasLimit,
        userOp.verificationGasLimit,
        userOp.preVerificationGas,
        userOp.maxFeePerGas,
        userOp.maxPriorityFeePerGas,
        keccak256(userOp.paymasterAndData),
        block.chainid,
        address(IEntryPoint(msg.sender))
    ));
    require(computedHash == userOpHash, "Hash mismatch");
    // ... signature verification
}
Enter fullscreen mode Exit fullscreen mode

Attack Vector #6: Factory Initialization Front-Running

The bug: ERC-4337 wallets are often deployed via factory contracts using CREATE2. The initCode in a UserOp specifies the factory and initialization parameters. An attacker who observes a pending UserOp can front-run the wallet deployment with different initialization parameters at the same address (using CREATE2 salt manipulation).

The subtle version: The attacker deploys a wallet at the predicted address with a backdoor module pre-installed, then lets the original UserOp's execution proceed. The user thinks their wallet was deployed correctly, but the attacker has persistent access.

Defense:

// Factory must bind deployment to the UserOp sender's signature
function createAccount(
    address owner,
    uint256 salt
) public returns (address) {
    // Salt MUST include the owner to prevent front-running
    bytes32 actualSalt = keccak256(abi.encodePacked(owner, salt));
    address addr = Create2.deploy(0, actualSalt, walletBytecode);
    IWallet(addr).initialize(owner);
    return addr;
}
Enter fullscreen mode Exit fullscreen mode

The owner address in the salt ensures only a UserOp signed by that owner can trigger meaningful deployment at the predicted address.


The 6-Point ERC-4337 Security Checklist

Before deploying any account abstraction component in production:

# Check Component
1 Full EIP-712 domain separator with chainId + verifyingContract Wallet
2 Storage access tracing + opcode banning in simulation Bundler
3 Layered spending limits (per-op, per-user, global) Paymaster
4 Cross-UserOp storage conflict detection in bundles Bundler
5 Independent UserOp hash recomputation in validateUserOp Wallet
6 Owner-bound CREATE2 salt in wallet factory Factory

Tools for Auditing ERC-4337 Implementations

  • ERC-4337 Bundler Test Suite: Official compatibility tests — run these against your bundler before mainnet
  • Slither's AA detectors: Recent versions include specific detectors for missing domain separator fields and unbounded paymaster spending
  • Foundry invariant tests: Write invariant tests that fuzz handleOps with adversarial UserOp batches — test for cross-op reentrancy and gas griefing resilience
  • Tenderly simulation: Trace handleOps execution to verify storage access patterns match simulation assumptions

What's Next: ERC-7562 and the Road to Safer Account Abstraction

The ERC-7562 specification (the "validation rules" EIP) aims to formalize many of the simulation-time restrictions that bundlers currently implement ad-hoc. Key improvements:

  • Explicit storage access rules that prevent cross-account interference during validation
  • Standardized staking and reputation for paymasters and factories
  • Formal gas accounting that eliminates the griefing vectors in handleOps

But ERC-7562 is still in draft. Until it's finalized and widely adopted, every protocol integrating ERC-4337 needs to treat the bundler-paymaster-wallet pipeline as a hostile environment and validate assumptions at every trust boundary.


Account abstraction is the future of Ethereum UX. But "smart wallets" are only as smart as their weakest trust assumption. Audit the gaps between components — that's where the money goes.


About DreamWork Security: We research and publish deep dives on DeFi security vulnerabilities, smart contract audit methodologies, and defense patterns for protocol developers. Follow for weekly analysis of the latest exploits and defense strategies.

Top comments (0)