DEV Community

ohmygod
ohmygod

Posted on

ERC-4337 Smart Account Security: 6 Deadly Mistakes That Let Attackers Drain Wallets Like Stealing a Private Key

The Problem Nobody's Talking About

Account Abstraction (AA) was supposed to fix crypto UX forever. No more seed phrases. Social recovery. Gas sponsorship. Batch transactions. The dream.

But here's what the dream looks like in practice: Trail of Bits audited dozens of ERC-4337 smart account implementations in early 2026 and found the same 6 critical vulnerability patterns recurring across nearly every single one.

Each flaw is equivalent to a private key compromise — full wallet drain, no recovery.

As of March 2026, over $2.3 billion sits in smart contract wallets. ERC-4337 adoption is accelerating with major wallets (Safe, ZeroDev, Biconomy, Alchemy) pushing AA as the default. The attack surface is massive and growing.

Let me walk you through each vulnerability with real code, working exploit logic, and the fixes.


1. Missing Access Control on execute()

Severity: Critical — anyone can drain the wallet

This is the most embarrassing one, and it's disturbingly common. The execute() function — which sends arbitrary transactions from the wallet — has no access control.

// ❌ VULNERABLE — no access control
contract SmartAccount {
    function execute(
        address target, 
        uint256 value, 
        bytes calldata data
    ) external {
        (bool success, ) = target.call{value: value}(data);
        require(success, "execution failed");
    }
}
Enter fullscreen mode Exit fullscreen mode

The exploit is trivial:

// Attacker calls execute() directly
vulnerableWallet.execute(
    address(USDC),           // target: USDC contract
    0,                       // no ETH needed
    abi.encodeWithSignature(
        "transfer(address,uint256)", 
        attacker, 
        IERC20(USDC).balanceOf(address(vulnerableWallet))
    )
);
// All USDC transferred to attacker. Done.
Enter fullscreen mode Exit fullscreen mode

Why it happens: Developers assume execute() is only called via the EntryPoint's handleOps() flow. They forget that any address can call a contract's public functions directly.

The fix:

// ✅ SECURE — proper access control
contract SmartAccount {
    address public immutable entryPoint;
    address public owner;

    modifier onlyAuthorized() {
        require(
            msg.sender == entryPoint || 
            msg.sender == owner ||
            msg.sender == address(this), // for self-calls in batch
            "unauthorized"
        );
        _;
    }

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

Audit checklist:

  • [ ] Can execute() be called by addresses other than EntryPoint, owner, or self?
  • [ ] Are there batch/multi-call functions with the same missing check?
  • [ ] Does the fallback() or receive() function expose execution paths?

2. Incomplete Signature Validation (Gas Fields)

Severity: Critical — signature can be replayed with different gas parameters

ERC-4337's UserOperation includes gas fields: callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas, maxPriorityFeePerGas. Many implementations exclude these fields from the signature hash.

// ❌ VULNERABLE — gas fields not included in hash
function _validateSignature(
    UserOperation calldata userOp,
    bytes32 userOpHash
) internal view returns (uint256 validationData) {
    // This hash is computed by EntryPoint and includes ALL fields
    // But some implementations compute their OWN hash, skipping gas:
    bytes32 customHash = keccak256(abi.encode(
        userOp.sender,
        userOp.nonce,
        userOp.callData
        // Missing: callGasLimit, verificationGasLimit, 
        // preVerificationGas, maxFeePerGas, maxPriorityFeePerGas
    ));
    // Signature is only over partial data!
    require(ECDSA.recover(customHash, userOp.signature) == owner);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

The attack:

  1. User signs a legitimate UserOp with specific gas parameters
  2. Bundler (or MEV bot) intercepts the UserOp
  3. Attacker modifies gas fields — sets maxFeePerGas to extreme values
  4. Signature still validates because gas fields weren't signed
  5. User's wallet pays 100x the expected gas fee
  6. Attacker (running as block builder) captures the inflated gas payment

In extreme cases, the attacker can set callGasLimit so low that the inner call reverts but the wallet still pays for the full UserOp — burning the user's ETH with zero useful execution.

The fix:

// ✅ SECURE — use the EntryPoint-provided hash (covers ALL fields)
function _validateSignature(
    UserOperation calldata userOp,
    bytes32 userOpHash  // This is already computed correctly by EntryPoint
) internal view returns (uint256 validationData) {
    // ALWAYS use the EntryPoint-provided hash
    bytes32 ethSignedHash = ECDSA.toEthSignedMessageHash(userOpHash);
    require(ECDSA.recover(ethSignedHash, userOp.signature) == owner);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

Never compute your own hash. The EntryPoint's getUserOpHash() already includes every field plus the entryPoint address and chainId. Use it.


3. State Modification During Validation

Severity: Critical — batch semantics break, funds can be misdirected

The ERC-4337 spec says validateUserOp should be a view-like function — it checks if the operation is authorized. But nothing in the EVM actually prevents state modifications during validation.

// ❌ VULNERABLE — state modified during validation
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    // This looks innocent...
    lastValidatedNonce = userOp.nonce;  // State write!
    pendingTarget = extractTarget(userOp.callData);  // State write!

    if (missingAccountFunds > 0) {
        // Prefund the EntryPoint
        (bool success, ) = payable(msg.sender).call{
            value: missingAccountFunds
        }("");
    }

    return _checkSignature(userOp, userOpHash);
}
Enter fullscreen mode Exit fullscreen mode

Why this is dangerous: When the EntryPoint processes a batch of UserOps, it runs ALL validations first, then ALL executions. If validation modifies state, the execution phase sees state that was modified by the last validated op, not the one being executed.

Exploit scenario:

  1. Attacker submits two UserOps in one bundle
  2. Op A validates: sets pendingTarget = legitimate_address
  3. Op B validates: overwrites pendingTarget = attacker_address
  4. Op A executes: reads pendingTarget → sends funds to attacker instead of legitimate address

The fix:

// ✅ SECURE — no state writes during validation
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    // ONLY do signature verification and prefunding
    // NO state modifications

    if (missingAccountFunds > 0) {
        (bool success, ) = payable(msg.sender).call{
            value: missingAccountFunds
        }("");
        // This is the ONLY acceptable state change (required by spec)
    }

    return _checkSignature(userOp, userOpHash);
}
Enter fullscreen mode Exit fullscreen mode

Audit checklist:

  • [ ] Does validateUserOp write to any storage slots beyond EntryPoint prefunding?
  • [ ] Are there any SSTORE opcodes reachable from the validation path?
  • [ ] Could a bundler batch two UserOps that interact via shared state?

4. ERC-1271 Signature Replay

Severity: High — signed messages can be replayed across chains or contexts

Smart accounts implement ERC-1271 for isValidSignature() — this lets other contracts verify that the smart wallet "signed" a message. The problem: many implementations don't include the wallet address or chain ID in the signature verification.

// ❌ VULNERABLE — no domain separation
function isValidSignature(
    bytes32 hash,
    bytes calldata signature
) external view returns (bytes4) {
    // Owner signed this hash... but for WHICH wallet? WHICH chain?
    if (ECDSA.recover(hash, signature) == owner) {
        return IERC1271.isValidSignature.selector;
    }
    return 0xffffffff;
}
Enter fullscreen mode Exit fullscreen mode

The attack:

  1. User owns SmartWallet-A on Ethereum and SmartWallet-B on Arbitrum (same owner key)
  2. User signs a permit for SmartWallet-A to approve USDC spending
  3. Attacker replays the same signature against SmartWallet-B on Arbitrum
  4. SmartWallet-B's isValidSignature returns valid (same owner, same hash)
  5. Attacker drains USDC from SmartWallet-B

This also works across different wallets on the same chain if they share an owner.

The fix:

// ✅ SECURE — proper domain separation
function isValidSignature(
    bytes32 hash,
    bytes calldata signature
) external view returns (bytes4) {
    // Include wallet address and chain ID in the verification
    bytes32 domainSeparator = keccak256(abi.encode(
        keccak256("EIP712Domain(address verifyingContract,uint256 chainId)"),
        address(this),
        block.chainid
    ));
    bytes32 typedHash = keccak256(abi.encodePacked(
        "\x19\x01",
        domainSeparator,
        hash
    ));

    if (ECDSA.recover(typedHash, signature) == owner) {
        return IERC1271.isValidSignature.selector;
    }
    return 0xffffffff;
}
Enter fullscreen mode Exit fullscreen mode

Key insight: EIP-712 domain separation exists for exactly this reason. Use it. Always include verifyingContract (the wallet address) and chainId.


5. Insufficient Revert Protection

Severity: Medium-High — silent failures lead to fund loss

ERC-4337 has a nuanced execution model. When the EntryPoint calls execute(), a revert in the inner call doesn't necessarily revert the entire UserOp. The wallet still pays gas. This creates a dangerous pattern:

// ❌ VULNERABLE — inner call failure is silently swallowed
function execute(
    address target,
    uint256 value,
    bytes calldata data
) external onlyEntryPoint {
    (bool success, bytes memory result) = target.call{value: value}(data);
    // No revert on failure! Gas is consumed, nothing happened.
    // User thinks the tx went through, but it silently failed.
}
Enter fullscreen mode Exit fullscreen mode

The attack scenario:

  1. User submits a UserOp to swap 10 ETH for USDC on Uniswap
  2. Bundler manipulates mempool timing — the swap will fail (price moved, slippage exceeded)
  3. execute() calls Uniswap → call fails → no revert
  4. User pays full gas cost (~$5-50 depending on network)
  5. 10 ETH is still in the wallet but user's gas is burned
  6. Bundler repeats this, draining gas from the wallet over time

More dangerously, for multi-step operations:

// Multi-call where step 2 silently fails
// Step 1: approve USDC spending → succeeds
// Step 2: deposit USDC into vault → fails silently
// Result: USDC is approved but never deposited — attacker can transferFrom
Enter fullscreen mode Exit fullscreen mode

The fix:

// ✅ SECURE — revert on inner call failure
function execute(
    address target,
    uint256 value,
    bytes calldata data
) external onlyEntryPoint {
    (bool success, bytes memory result) = target.call{value: value}(data);
    if (!success) {
        // Bubble up the revert reason
        assembly {
            revert(add(result, 32), mload(result))
        }
    }
}

// For batch operations — atomic by default
function executeBatch(
    address[] calldata targets,
    uint256[] calldata values,
    bytes[] calldata datas
) external onlyEntryPoint {
    require(targets.length == values.length && values.length == datas.length);
    for (uint256 i = 0; i < targets.length; i++) {
        (bool success, bytes memory result) = targets[i].call{
            value: values[i]
        }(datas[i]);
        if (!success) {
            assembly {
                revert(add(result, 32), mload(result))
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

6. ERC-7702 Compatibility Gaps

Severity: Medium — affects wallets upgrading to ERC-7702 delegation

ERC-7702 (Pectra upgrade, live on Ethereum since early 2026) allows EOAs to delegate to smart contract code. This creates a new class of issues for existing ERC-4337 accounts:

// ❌ VULNERABLE — doesn't handle ERC-7702 delegation correctly
function validateUserOp(
    UserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 missingAccountFunds
) external returns (uint256 validationData) {
    // Assumes msg.sender is always the EntryPoint
    // But with ERC-7702, the "sender" field in UserOp could be
    // an EOA that has delegated to this contract's code

    // This check fails for ERC-7702 delegated accounts:
    require(msg.sender == ENTRY_POINT, "not entry point");

    // The nonce management also breaks — EOA nonces and
    // EntryPoint nonces are separate systems
}
Enter fullscreen mode Exit fullscreen mode

The issue: ERC-7702 lets an EOA say "execute my transactions using this smart account's code." But the smart account code was written assuming it's deployed as a standalone contract with its own storage. When an EOA delegates to it:

  • Storage slots may overlap with existing EOA state
  • Nonce tracking between ERC-4337 and ERC-7702 can desync
  • address(this) returns the EOA address, not the implementation
  • EXTCODESIZE checks break (EOA has zero code, delegation is transparent)

The fix:

// ✅ SECURE — ERC-7702 aware
contract SmartAccount {
    // Use EIP-7201 namespaced storage to avoid slot collisions
    bytes32 constant ACCOUNT_STORAGE_SLOT = 
        keccak256("smartaccount.storage.v1") - 1;

    struct AccountStorage {
        address owner;
        uint256 nonce;
        mapping(bytes32 => bool) usedSignatures;
    }

    function _getStorage() internal pure returns (AccountStorage storage s) {
        bytes32 slot = ACCOUNT_STORAGE_SLOT;
        assembly {
            s.slot := slot
        }
    }

    function validateUserOp(
        UserOperation calldata userOp,
        bytes32 userOpHash,
        uint256 missingAccountFunds
    ) external returns (uint256 validationData) {
        // Works for both standalone deployment AND ERC-7702 delegation
        // because storage is namespaced
        AccountStorage storage store = _getStorage();

        // Verify signature against stored owner
        bytes32 ethSignedHash = ECDSA.toEthSignedMessageHash(userOpHash);
        require(ECDSA.recover(ethSignedHash, userOp.signature) == store.owner);

        return 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

The Audit Checklist

If you're building or auditing an ERC-4337 smart account, run through this:

# Check Severity
1 Can execute() be called by anyone? Critical
2 Are ALL UserOp fields included in signature hash? Critical
3 Does validateUserOp modify state beyond prefunding? Critical
4 Is isValidSignature replay-safe across chains/wallets? High
5 Do inner call failures revert the UserOp? Medium-High
6 Is storage namespaced for ERC-7702 compatibility? Medium

One "yes" to any Critical = full wallet drain potential.


Why This Matters Now

Account Abstraction isn't optional anymore. It's the direction the entire Ethereum ecosystem is heading. Pectra (ERC-7702) is live. Major wallets are AA-first. Layer 2s are built around smart accounts.

The $2.3B currently in smart wallets is going to be $20B+ by end of 2026. Every dollar is protected (or not) by the quality of these implementations.

Trail of Bits found these 6 patterns across dozens of implementations. Not one or two edge cases — dozens. The same mistakes, repeated by different teams, because the ERC-4337 spec is complex and the security surface is non-obvious.

If you're a security researcher, these patterns are your new audit checklist. If you're building a smart account, this is your "must not ship without" list.

The tooling gap is real. Standard Solidity analysis tools (Slither, Mythril) don't have ERC-4337-specific detectors yet. Custom Semgrep rules or manual review are your best options today. That gap is also an opportunity — building AA-specific security tooling is a wide-open space.


References


This article is part of the DeFi Security Research series. Follow @ohmygod for weekly deep-dives into smart contract vulnerabilities, audit techniques, and security tooling.

Top comments (0)