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:
-
User constructs and signs a
UserOperationoff-chain (callData + nonce + gas params + signature) -
Bundler simulates it locally, batches it with other ops, submits to the
EntryPointviahandleOps -
EntryPoint calls
validateUserOpon the smart account (signature + gas check) - If a Paymaster is involved, the EntryPoint validates sponsorship
-
EntryPoint calls back into the smart account to
executethe 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");
}
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");
}
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 callGasLimitmaxFeePerGasmaxPriorityFeePerGas
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;
}
✅ 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;
}
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");
}
}
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");
}
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
- Cross-account replay: Same owner controls Account A and Account B. Signature for A is valid on B.
- 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;
}
✅ 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;
}
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
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
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;
}
✅ 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;
}
The ERC-4337 Security Audit Checklist
Use this checklist when building or auditing smart accounts:
Access Control
- [ ]
executerestricted to EntryPoint (andaddress(this)for self-calls) - [ ] Module install/uninstall restricted to EntryPoint or self
- [ ] Upgrade functions restricted to EntryPoint or self
- [ ] No unprotected
delegatecallpaths
Signature Validation
- [ ]
validateUserOpvalidates against the fulluserOpHash(not partial fields) - [ ] All gas fields are cryptographically bound to the signature
- [ ] ERC-1271 signatures include
address(this)andblock.chainid - [ ] No signature reuse across accounts or chains
- [ ] Custom signature schemes are at least as strong as ECDSA
State Management
- [ ]
validateUserOpdoes 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
- [ ]
delegatecalltargets are whitelisted or registry-checked
Bundler Compatibility
- [ ] Validation follows ERC-4337 opcode restrictions
- [ ] No reliance on
TIMESTAMP,BLOCKHASH, or other volatile opcodes - [ ] Paymaster
postOphandles 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)