DEV Community

ohmygod
ohmygod

Posted on

ERC-4337 Smart Account Security: 6 Critical Vulnerabilities That Could Drain Your Wallet

Account abstraction is rewriting the rules of Ethereum wallets. ERC-4337 replaces rigid "one private key controls everything" models with programmable smart accounts that support batched transactions, social recovery, spending limits, and gasless UX. Major protocols — Safe, ZeroDev, Biconomy, Alchemy — are shipping ERC-4337 wallets to millions of users.

But that programmability is a double-edged sword. A single implementation bug in a smart account can be as catastrophic as leaking a private key.

Trail of Bits published a landmark audit report on March 11, 2026, identifying six recurring vulnerability patterns across dozens of ERC-4337 smart account implementations. In this article, we'll dissect each pattern with vulnerable and fixed code, explain the attack mechanics, and provide an audit checklist you can use today.

Quick Refresher: How ERC-4337 Works

Before diving into bugs, here's the 30-second mental model:

  1. User constructs and signs a UserOperation off-chain (callData + nonce + gas params + signature)
  2. Bundler simulates it locally, batches it with other ops, submits to the EntryPoint via handleOps
  3. EntryPoint calls validateUserOp on the smart account (signature + gas check)
  4. If a Paymaster is involved, the EntryPoint validates sponsorship
  5. EntryPoint calls back into the smart account to execute the actual operation

The critical insight: validation and execution are separate phases, and in a bundle, all validations run before any executions. This batch semantics is the root cause of several vulnerabilities.

Vulnerability #1: Missing Access Control on execute

Severity: Critical — Full wallet drain

The most basic yet devastating bug. If your execute function has no access control, anyone can call it directly and move all funds.

❌ Vulnerable

function execute(address target, uint256 value, bytes calldata data) 
    external 
{
    (bool ok,) = target.call{value: value}(data);
    require(ok, "exec failed");
}
Enter fullscreen mode Exit fullscreen mode

Any address can call this. Game over.

✅ Fixed

address public immutable entryPoint;

function execute(address target, uint256 value, bytes calldata data) 
    external 
{
    require(
        msg.sender == entryPoint || msg.sender == address(this), 
        "unauthorized"
    );
    (bool ok,) = target.call{value: value}(data);
    require(ok, "exec failed");
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • Only the EntryPoint (or the account itself for self-calls) should trigger execution
  • For ERC-7579 modular accounts, also whitelist vetted executor modules
  • Audit every external/public function — not just execute

Vulnerability #2: Incomplete Signature Validation (Gas Field Omission)

Severity: High — ETH drain via gas inflation

Many implementations validate the callData but forget to bind the gas parameters to the signature. The UserOperation includes five gas-related fields:

  • preVerificationGas
  • verificationGasLimit
  • callGasLimit
  • maxFeePerGas
  • maxPriorityFeePerGas

If these aren't signed, a malicious bundler or frontrunner can inflate them — especially preVerificationGas — and drain ETH from the account as "gas reimbursement."

❌ Vulnerable — Only validates callData

function validateUserOp(
    UserOperation calldata op, 
    bytes32 /*userOpHash*/, 
    uint256 /*missingFunds*/
) external returns (uint256) {
    // Only checks callData is approved — gas fields are unsigned!
    require(_isApprovedCall(op.callData, op.signature), "bad sig");
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

✅ Fixed — Validates the full userOpHash

function validateUserOp(
    UserOperation calldata op, 
    bytes32 userOpHash, // includes ALL fields by spec
    uint256 /*missingFunds*/
) external returns (uint256) {
    require(_isApprovedCall(userOpHash, op.signature), "bad sig");
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Why this matters: The userOpHash provided by the EntryPoint includes all UserOperation fields by spec. Always validate against it — never roll your own partial hash.

Vulnerability #3: State Modification During Validation

Severity: High — Cross-operation state clobbering

This one is subtle and stems from ERC-4337's batch semantics. The EntryPoint validates ALL operations in a bundle before executing any of them. If your validateUserOp writes to storage, a later operation's validation can overwrite that state before your execution runs.

❌ Vulnerable — Caches signer in storage

contract VulnerableAccount {
    address public pendingSigner;

    function validateUserOp(
        UserOperation calldata op, 
        bytes32 userOpHash, 
        uint256
    ) external returns (uint256) {
        address signer = recover(userOpHash, op.signature);
        require(signer == owner1 || signer == owner2, "unauthorized");

        // DANGEROUS: can be overwritten by next op's validation
        pendingSigner = signer;
        return 0;
    }

    function executeWithSigner(
        address target, uint256 value, bytes calldata data
    ) external onlyEntryPoint {
        // May use the WRONG signer!
        bytes memory payload = abi.encodePacked(data, pendingSigner);
        (bool ok,) = target.call{value: value}(payload);
        require(ok, "exec failed");
    }
}
Enter fullscreen mode Exit fullscreen mode

Attack scenario: Owner1 signs Op A. Owner2 signs Op B. Both are in the same bundle. Validation runs A then B — pendingSigner is now Owner2. When Op A executes, it uses Owner2's identity instead of Owner1's.

✅ Fixed — Pass data through callData, not storage

function validateUserOp(
    UserOperation calldata op, 
    bytes32 userOpHash, 
    uint256
) external returns (uint256) {
    address signer = recover(userOpHash, op.signature);
    require(signer == owner1 || signer == owner2, "unauthorized");
    // Don't write state — validation should be stateless
    return 0;
}

function execute(
    address target, uint256 value, bytes calldata data
) external onlyEntryPoint {
    // Signer identity is encoded in callData, not storage
    (bool ok,) = target.call{value: value}(data);
    require(ok, "exec failed");
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: validateUserOp should be pure/view with respect to your account's storage. If you absolutely must persist data, key it by userOpHash and delete it after use.

Vulnerability #4: ERC-1271 Signature Replay

Severity: High — Cross-account and cross-chain replay

ERC-1271 lets contracts validate signatures via isValidSignature(bytes32 hash, bytes signature). The problem: if the implementation verifies the owner signed the hash without binding the signature to the specific smart account and chain, that signature can be replayed.

Attack Scenarios

  1. Cross-account replay: Same owner controls Account A and Account B. Signature for A is valid on B.
  2. Cross-chain replay: Same account deployed on Ethereum and Arbitrum. Signature from Ethereum works on Arbitrum.

❌ Vulnerable — No domain binding

function isValidSignature(bytes32 hash, bytes calldata sig) 
    external view returns (bytes4) 
{
    address signer = ECDSA.recover(hash, sig);
    if (signer == owner) return 0x1626ba7e; // MAGIC_VALUE
    return 0xffffffff;
}
Enter fullscreen mode Exit fullscreen mode

✅ Fixed — Domain-bound signature

function isValidSignature(bytes32 hash, bytes calldata sig) 
    external view returns (bytes4) 
{
    // Bind to this specific account + chain
    bytes32 domainHash = keccak256(abi.encode(
        hash, 
        address(this), 
        block.chainid
    ));
    address signer = ECDSA.recover(domainHash, sig);
    if (signer == owner) return 0x1626ba7e;
    return 0xffffffff;
}
Enter fullscreen mode Exit fullscreen mode

Best practice: Always include address(this) and block.chainid in the signed message. Consider using EIP-712 typed data for structured domain separation.

Vulnerability #5: Bundler Griefing via Simulation-Execution Divergence

Severity: Medium — Economic attack on infrastructure

This vulnerability targets bundlers rather than end users, but it threatens the entire ERC-4337 ecosystem's economic viability.

The attack: Submit a UserOperation that passes the bundler's off-chain simulation but fails on-chain. Front-run the bundler's transaction with a state change (nonce increment, balance drain, storage flip) that causes the op to revert. The bundler pays gas for nothing.

Attacker submits UserOp → Bundler simulates (passes) → 
Attacker front-runs with state change → 
Bundler's tx reverts → Bundler loses gas
Enter fullscreen mode Exit fullscreen mode

Defenses (Bundler Side)

# Bundler mitigation: reputation-based throttling
class BundlerReputation:
    def __init__(self):
        self.accounts = {}  # address -> {fails, successes, stake}

    def should_include(self, sender: str) -> bool:
        rep = self.accounts.get(sender, {"fails": 0, "successes": 0})
        if rep["fails"] > 3 and rep["successes"] == 0:
            return False  # Throttled
        return True

    def record_revert(self, sender: str):
        self.accounts.setdefault(sender, {"fails": 0, "successes": 0})
        self.accounts[sender]["fails"] += 1
Enter fullscreen mode Exit fullscreen mode

Defenses (Protocol Side)

  • ERC-4337 restricts opcodes in validation (no BLOCKHASH, TIMESTAMP, etc.)
  • Accounts and paymasters must stake ETH — repeated griefing burns reputation
  • EIP-5189 proposes "Endorser" contracts to help bundlers filter malicious ops

Vulnerability #6: Unsafe Delegatecall in Modular Accounts

Severity: Critical — Full account takeover

ERC-7579 modular accounts use delegatecall to execute module logic in the account's context. If module installation isn't properly gated, an attacker can install a malicious module that has full control.

❌ Vulnerable — No validation on module installation

function installModule(address module, bytes calldata initData) external {
    // Anyone can install any module!
    (bool ok,) = module.delegatecall(
        abi.encodeCall(IModule.onInstall, (initData))
    );
    require(ok, "install failed");
    modules[module] = true;
}
Enter fullscreen mode Exit fullscreen mode

✅ Fixed — Strict access control + registry check

// ERC-7484 Module Registry
IERC7484 public immutable moduleRegistry;

function installModule(address module, bytes calldata initData) external {
    require(
        msg.sender == address(this) || msg.sender == entryPoint, 
        "unauthorized"
    );
    // Verify module is audited and registered
    require(moduleRegistry.isRegistered(module), "unregistered module");

    (bool ok,) = module.delegatecall(
        abi.encodeCall(IModule.onInstall, (initData))
    );
    require(ok, "install failed");
    modules[module] = true;
}
Enter fullscreen mode Exit fullscreen mode

The ERC-4337 Security Audit Checklist

Use this checklist when building or auditing smart accounts:

Access Control

  • [ ] execute restricted to EntryPoint (and address(this) for self-calls)
  • [ ] Module install/uninstall restricted to EntryPoint or self
  • [ ] Upgrade functions restricted to EntryPoint or self
  • [ ] No unprotected delegatecall paths

Signature Validation

  • [ ] validateUserOp validates against the full userOpHash (not partial fields)
  • [ ] All gas fields are cryptographically bound to the signature
  • [ ] ERC-1271 signatures include address(this) and block.chainid
  • [ ] No signature reuse across accounts or chains
  • [ ] Custom signature schemes are at least as strong as ECDSA

State Management

  • [ ] validateUserOp does NOT modify account storage
  • [ ] No cross-phase state dependencies (validation → execution)
  • [ ] Nonce management follows EntryPoint conventions

Modular Security (ERC-7579)

  • [ ] Module registry (ERC-7484) validation before installation
  • [ ] Modules can't bypass account access control
  • [ ] delegatecall targets are whitelisted or registry-checked

Bundler Compatibility

  • [ ] Validation follows ERC-4337 opcode restrictions
  • [ ] No reliance on TIMESTAMP, BLOCKHASH, or other volatile opcodes
  • [ ] Paymaster postOp handles edge cases gracefully

The Bigger Picture

ERC-4337 adoption is accelerating — smart accounts now hold over $8B in TVL across Ethereum, Polygon, and Base. The security surface is fundamentally different from EOA wallets:

Risk EOA ERC-4337 Smart Account
Key compromise Full loss Recoverable (social recovery)
Access control bugs N/A Full wallet drain
Signature replay Nonce protects Must bind domain manually
Gas manipulation Not applicable ETH drain via gas inflation
Module vulnerabilities N/A Account takeover via delegatecall

The trade-off is clear: ERC-4337 gives you more features but requires more security diligence. Every custom validation function, every module installation, every signature scheme is a potential attack surface.

If you're building smart accounts, audit them like you'd audit a DeFi protocol — because they are DeFi infrastructure now.


This analysis is based on the Trail of Bits research published March 11, 2026, combined with our own audit experience. For the original research, see their blog post.

Follow @ohmygod for weekly DeFi security research.

Top comments (0)