EVM VULNERABILITY PATTERNS
> Vulnerability patterns specific to Cosmos EVM chains (evmOS, Ethermint, Initia, Berachain, Sei, etc.). These chains run two execution runtimes (EVM + Cosmos SDK) with independent state that must stay in sync. This is the most frequently discovered vulnerability class in Cosmos EVM chains — 7+ findings across multiple chains, $7M+ in real losses. Referenced by [SKILL.md](../SKILL.md).
Overview
Cosmos EVM Vulnerability Patterns
Vulnerability patterns specific to Cosmos EVM chains (evmOS, Ethermint, Initia, Berachain, Sei, etc.). These chains run two execution runtimes (EVM + Cosmos SDK) with independent state that must stay in sync. This is the most frequently discovered vulnerability class in Cosmos EVM chains — 7+ findings across multiple chains, $7M+ in real losses. Referenced by SKILL.md.
1 EVM REVERT DOES NOT ROLLBACK COSMOS STATE
Description: Precompiles bridge EVM and Cosmos state machines. When a Solidity revert, try/catch, or out-of-gas (OOG) occurs, EVM state rolls back automatically, but Cosmos keeper writes made during the precompile call persist. An attacker wraps a precompile call in try/catch, triggers a revert after the Cosmos-side effect executes, and gets the EVM-side effect undone while keeping the Cosmos-side effect. Every Cosmos EVM chain is vulnerable until it implements snapshot-based atomic precompile wrappers.
Detection Patterns:
// VULNERABLE: Solidity try/catch around precompile call
// EVM state rolls back on revert, but Cosmos delegation persists
contract InfiniteDelegate {
IStaking constant STAKING = IStaking(0x...1003);
function exploit(address validator, uint256 amount) external {
try this._delegate(validator, amount) {} catch {}
// EVM: tokens refunded (revert undid the transfer)
// Cosmos: delegation still exists (keeper write persisted)
// Net effect: delegation increased without balance decreasing
}
function _delegate(address validator, uint256 amount) external {
STAKING.delegate(validator, amount);
revert(); // EVM state rolls back, Cosmos state does NOT
}
}
What to Check:
- ALL precompile
Run()methods wrapped in atomic snapshot/revert (no partial execution on OOG or error) - EVM
revert/try/catch/ OOG propagates rollback to Cosmos keeper state — test with Soliditytry/catcharound every precompile call - IBC operations triggered via precompiles are atomic with the EVM transaction
References:
- EVM staking precompile infinite delegation (Finding 7)
- Saga $7M exploit (Finding 5)
2 STATEDB.COMMIT() DURING PRECOMPILE EXECUTION
Description: Certain precompile calls (e.g., delegationTotalRewards) trigger StateDB.Commit(), writing state to permanent storage mid-execution. A subsequent revert only undoes cached in-memory state — permanent storage retains the committed state. This creates orphaned contracts with withdrawable balances. Exponential multiplication is possible: 1 ETH becomes 1,048,576 ETH in 20 loops.
Detection Patterns:
// VULNERABLE: Precompile triggers StateDB.Commit() (Finding 6)
// Calling certain precompiles caused StateDB.Commit() to write
// state to permanent storage. A subsequent revert only undoes
// cached in-memory state, but permanent storage retains the
// committed state.
// Result: Orphaned contracts with balances are accessible and withdrawable.
What to Check:
-
StateDB.Commit()is NEVER called during precompile execution (only at transaction finalization) - Precompile code paths audited for any indirect
Commit()triggers (e.g., via keeper calls that flush caches) - StateDB journal entries cover all state modifications made by precompiles
Grep Patterns (use Grep tool with glob: "*.go" in x/):
StateDB|stateDB— then check results forcommitcontextfunc.*Run\\(.*vm\\.EVM— precompile Run methods
References:
- Evmos infinite mint (Finding 6)
3 PARTIAL PRECOMPILE EXECUTION (OOG)
Description: A precompile Run() performs multiple Cosmos state changes sequentially (e.g., transfer rewards then clear claimable counter). If execution runs out of gas between steps, early state changes persist while later ones are skipped. The attacker receives rewards but the claimable counter is never reset — repeat to drain the module.
Detection Patterns:
// VULNERABLE: Precompile Run() not atomic (Finding 20)
func (p DistributionPrecompile) Run(evm *vm.EVM, input []byte) ([]byte, error) {
// Step 1: Transfer rewards to user (succeeds)
k.bankKeeper.SendCoins(ctx, moduleAcc, userAddr, rewards)
// Step 2: Clear claimable rewards (runs out of gas here!)
k.SetClaimableRewards(ctx, userAddr, sdk.Coins{})
// User received rewards but claimable counter never reset
// Repeat to drain distribution module
}
What to Check:
- Every precompile
Run()is wrapped in an atomic execution wrapper (snapshot before, revert on any error including OOG) - No multi-step state mutations without atomicity guarantees
- Gas estimation for precompile operations is accurate (underestimation enables OOG at attacker-chosen points)
Mitigation:
// SECURE: Wrap in atomic execution
func (p DistributionPrecompile) Run(evm *vm.EVM, input []byte) ([]byte, error) {
return p.RunAtomic(evm, input, func(ctx sdk.Context) ([]byte, error) {
// All operations atomic — snapshot + revert on any error including OOG
})
}
References:
- Partial precompile state writes (Finding 20)
4 REVERTED EVM CALLS LEAVE COSMOS MESSAGES IN QUEUE
Description: When an EVM call dispatches a Cosmos message (e.g., IBC transfer) via a precompile, and the EVM call subsequently reverts, the dispatched Cosmos message remains in the execution queue. The Cosmos message executes despite the EVM revert, enabling IBC transfers (and other Cosmos-side effects) without corresponding EVM-side state changes.
Detection Patterns:
// VULNERABLE: Initia — dispatched Cosmos messages remain in execution queue
// even when the EVM call that dispatched them reverts (Finding 26b)
// Solidity try/catch wraps a call that dispatches IBC transfer via precompile
// Revert undoes EVM state but IBC transfer message still in Cosmos execution queue
// Result: IBC transfers execute despite explicit reversion
What to Check:
- Cosmos message dispatch queue is cleared when EVM execution reverts
- All Cosmos messages dispatched from precompiles are tracked in the EVM journal and rolled back on revert
- Test: wrap every message-dispatching precompile call in Solidity
try/catchwith explicitrevert— verify no Cosmos messages execute
References:
- Initia Code4rena report (Finding 26b)
5 GAS LIMIT BYPASS VIA PRECOMPILE DISPATCH
Description: Solidity's call{gas: X}() restricts gas forwarded to an untrusted contract. But if that contract calls a precompile that dispatches a new Cosmos MsgCall, the new message gets the full parent transaction gas limit — not the restricted amount. This defeats gas-based reentrancy guards and any logic relying on gas limits to bound untrusted execution.
Detection Patterns:
// VULNERABLE: Initia — COSMOS_CONTRACT precompile bypasses gas restrictions (Finding 26c)
// Solidity call{gas: X}(untrustedContract) restricts gas
// But untrusted contract calls COSMOS_CONTRACT precompile to dispatch new MsgCall
// New MsgCall gets full parent transaction gas limit, not the restricted amount
What to Check:
- Gas forwarding through precompile dispatch does not bypass caller-imposed gas limits
- Precompile-dispatched messages inherit the remaining gas of the calling frame, not the transaction-level gas limit
- Gas-based reentrancy guards are not the sole defense (combine with mutex/state guards)
References:
- Initia Code4rena report (Finding 26c)
6 WRONG ROUTING IN MULTI-DENOM ERC20 OPERATIONS
Description: When processing multiple denominations in a loop, ERC20-related operations (burn, mint, community pool funding) accidentally use the full amount parameter (all denoms) instead of the individual coin being processed. If the loop contains both ERC20 and native denoms, the native denom amount is sent to the wrong destination on every iteration that hits the ERC20 path.
Detection Patterns:
// VULNERABLE: Initia — BurnCoins sends entire amount to community pool (Finding 26a)
// When burning ERC20 denominated coins in a loop,
// the function sends the full `amount` parameter (all denoms)
// instead of just the individual `coin` being processed
func (k Keeper) BurnCoins(ctx sdk.Context, amount sdk.Coins) error {
for _, coin := range amount {
if k.isERC20(coin.Denom) {
// WRONG: sends ALL coins, not just this one
k.communityPoolKeeper.FundCommunityPool(ctx, amount, moduleAddr)
}
}
}
What to Check:
- Multi-denom operations in loops use the individual
coin, not the fullamountparameter - ERC20/native denom branching logic tested with mixed-denom inputs
- Community pool / burn / mint amounts match the specific coin being processed
Mitigation:
// SECURE: Use the individual coin in the loop
func (k Keeper) BurnCoins(ctx sdk.Context, amount sdk.Coins) error {
for _, coin := range amount {
if k.isERC20(coin.Denom) {
k.communityPoolKeeper.FundCommunityPool(ctx, sdk.NewCoins(coin), moduleAddr)
} else {
k.bankKeeper.BurnCoins(ctx, moduleName, sdk.NewCoins(coin))
}
}
}
References:
- Initia Code4rena report (Finding 26a)
7 ETHERMINT ANTE HANDLER BYPASS (NESTED MESSAGES)
Description: Cosmos EVM chains add custom AnteHandler decorators for EVM-specific validation (gas price floors, tx type restrictions, EVM-specific fee checks). These decorators typically only inspect the outer message. An attacker wraps a blocked EVM message inside x/authz MsgExec or an x/group proposal — the inner message skips EVM-specific ante validation entirely.
Detection Patterns:
// VULNERABLE: AnteHandler only checks outer message type
func (d EthGasDecorator) AnteHandle(ctx sdk.Context, tx sdk.Tx, simulate bool, next sdk.AnteHandler) (sdk.Context, error) {
for _, msg := range tx.GetMsgs() {
// Only checks outer msg — does NOT recurse into MsgExec.Msgs!
if ethMsg, ok := msg.(*evmtypes.MsgEthereumTx); ok {
if err := d.validateGas(ethMsg); err != nil {
return ctx, err
}
}
}
return next(ctx, tx, simulate)
}
What to Check:
- Custom EVM AnteHandlers recurse into
x/authz MsgExecandx/groupproposal inner messages - EVM-specific fee/gas validation applies to nested EVM messages, not just top-level
- SDK
MaxUnpackAnyRecursionDepth = 10is not sufficient — semantic nesting (authz wrapping authz) must be handled by the ante handler itself - Integration tests submit EVM messages wrapped in
MsgExecto verify they are validated
Mitigation:
// SECURE: Recurse into nested messages
func (d EthGasDecorator) checkMsg(msg sdk.Msg) error {
if ethMsg, ok := msg.(*evmtypes.MsgEthereumTx); ok {
return d.validateGas(ethMsg)
}
if exec, ok := msg.(*authz.MsgExec); ok {
for _, inner := range exec.Msgs {
var innerMsg sdk.Msg
if err := d.cdc.UnpackAny(inner, &innerMsg); err != nil {
return err
}
if err := d.checkMsg(innerMsg); err != nil {
return err
}
}
}
return nil
}
References:
8 EVM HOOKS AND CROSS-MODULE INTERACTIONS
Description: Cosmos EVM chains use hooks (PostTxProcessing, PreTxProcessing) to synchronize EVM and Cosmos state after EVM transactions. The x/evm and x/erc20 modules fire these hooks on every EVM transaction. Bugs in hook logic — missing error propagation, state assumptions, reentrancy via hook callbacks — break the cross-module state invariant.
What to Check:
- EVM hooks from
x/evmandx/erc20modules reviewed for correctness - Hook errors propagate correctly (a failed hook must revert the entire EVM transaction)
- Hooks do not assume EVM state is finalized (it may still be in a cached context)
- Cross-module interactions between
x/evm,x/erc20,x/bank, andx/stakingdo not create circular dependencies or reentrancy - User interaction with module special addresses reviewed — EVM contracts should not be able to interact with Cosmos module accounts in unexpected ways
9 ETHEREUM ADDRESS VALIDATION
Description: Cosmos EVM chains handle two address formats: Bech32 (cosmos1...) and hex (0x...). The go-ethereum function common.HexToAddress() does NOT validate format and silently ignores errors — it returns a zero-padded address for any invalid input. Address mapping between the two formats must be bijective and validated.
Detection Patterns:
// VULNERABLE: HexToAddress does not verify format, ignores errors
addr := common.HexToAddress(userInput)
// "not_an_address" → 0x0000000000000000000000000000000000000000
// No error returned! Silently maps to zero address.
// VULNERABLE: Inconsistent address derivation
cosmosAddr := sdk.AccAddress(ethAddr.Bytes()) // Cosmos addr from ETH addr
ethAddr2 := common.BytesToAddress(cosmosAddr) // Round-trip may not match
What to Check:
- NOT using
common.HexToAddress()for user-supplied input — use a validating function instead - Address conversion between Bech32 and hex is validated in both directions
- Zero address (
0x000...) is explicitly rejected where it should not be valid - EVM contract addresses derived from Cosmos module accounts are deterministic and documented
Mitigation:
// SECURE: Validate hex address format before conversion
func ValidateHexAddress(addr string) (common.Address, error) {
if !common.IsHexAddress(addr) {
return common.Address{}, fmt.Errorf("invalid hex address: %s", addr)
}
return common.HexToAddress(addr), nil
}
10 CROSS-CHAIN TRANSACTION REPLAY AND CHAIN ID
Description: Cosmos EVM chains use an epoch-based chain ID format (chainname_NNNN-1) where the numeric part maps to the Ethereum chain ID used in EVM transaction signing. If the chain ID epoch is not correctly parsed or enforced, transactions signed for one chain can be replayed on another. Nonce management differ