All skills
Skillintermediate

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).

Claude Code Knowledge Pack7/10/2026

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 Solidity try/catch around every precompile call
  • IBC operations triggered via precompiles are atomic with the EVM transaction

References:


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 for commit context
  • func.*Run\\(.*vm\\.EVM — precompile Run methods

References:


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:


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/catch with explicit revert — verify no Cosmos messages execute

References:


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:


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 full amount parameter
  • 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:


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 MsgExec and x/group proposal inner messages
  • EVM-specific fee/gas validation applies to nested EVM messages, not just top-level
  • SDK MaxUnpackAnyRecursionDepth = 10 is not sufficient — semantic nesting (authz wrapping authz) must be handled by the ante handler itself
  • Integration tests submit EVM messages wrapped in MsgExec to 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/evm and x/erc20 modules 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, and x/staking do 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