DEV Community

ohmygod
ohmygod

Posted on

The Cosmos EVM Precompile Kill Chain: 3 Vulnerability Classes That Have Cost $10M+ — And the Atomic Wrapper That Stops Them All

The promise of Cosmos EVM is seductive: plug Ethereum's smart contract ecosystem into Cosmos's modular, sovereign-chain architecture. Ship an appchain with Solidity support in weeks instead of months.

The reality? Every Cosmos EVM chain inherits a dual-state machine — the EVM's StateDB and the Cosmos SDK's KVStore running in parallel — and the seams between them are where attackers live.

In Q1 2026 alone, three distinct precompile vulnerability classes have surfaced across the Cosmos EVM ecosystem, culminating in the $7M SagaEVM hack that drained an entire stablecoin's collateral pool. None were ordinary smart contract bugs. All exploited the fundamental tension between two state machines that were never designed to share a transaction boundary.

This post dissects each class, shows the exploit mechanics, and provides a concrete defense pattern — the atomic precompile wrapper — that would have prevented all three.


Why Precompiles Are the New Bridge

In vanilla Ethereum, precompiled contracts are boring: ecrecover, sha256, modexp. Fixed-function cryptographic utilities at hardcoded addresses.

Cosmos EVM chains do something far more dangerous. They use precompiles as gateways between the EVM world and native Cosmos modules:

  • Staking precompile → delegates/undelegates tokens via the x/staking module
  • Distribution precompile → claims staking rewards via x/distribution
  • ICS20 precompile → executes IBC token transfers via x/ibc-transfer
  • Governance precompile → submits/votes on proposals via x/gov

Each precompile must read from and write to both the EVM StateDB and the Cosmos KVStore within a single EVM call. This is where things break.


Class 1: The Infinite Mint — StateDB.Commit() During Precompile Execution

Affected: Evmos, FunctionX/PundiX (patched pre-exploitation)
Severity: Critical — unlimited token minting
Advisory: GHSA-68fc-7mhg-6f6c

The Bug

When a Solidity contract calls the staking precompile to delegate tokens, the precompile needs to:

  1. Debit the caller's EVM balance (StateDB)
  2. Create a delegation in the Cosmos staking module (KVStore)

The Evmos implementation called StateDB.Commit() inside the precompile execution to flush the EVM balance change to permanent storage. This seems reasonable — you want the balance debit to persist before the delegation is created.

The problem: Commit() bypasses the EVM's journal and snapshot mechanism.

The Exploit Flow

1. Attacker contract calls staking precompile: delegate(1000 EVMOS)
2. Precompile debits 1000 EVMOS from contract's EVM balance
3. Precompile calls StateDB.Commit() — balance change is now PERMANENT
4. Precompile creates delegation in Cosmos staking module
5. Attacker contract REVERTS the outer EVM call
6. EVM journal replays: restores contract balance to pre-call state
   BUT the Commit() already flushed — the journal restore adds 1000 EVMOS
   on top of the already-committed debit
7. Result: Attacker has 1000 EVMOS in EVM balance AND 1000 EVMOS delegated
8. Repeat infinitely
Enter fullscreen mode Exit fullscreen mode

The root cause is a state desynchronization: the EVM's revert mechanism (journal + snapshots) doesn't know that Commit() already persisted the balance change, so it "restores" a balance that was never actually lost from permanent storage's perspective.

Why It's Subtle

In standard EVM execution, Commit() is called exactly once, after the entire transaction completes. Precompile authors who call it mid-execution are violating an invariant that go-ethereum's state management was never designed to handle.

// DANGEROUS: This pattern breaks EVM state consistency
func (p *StakingPrecompile) Delegate(ctx sdk.Context, stateDB *statedb.StateDB, args ...interface{}) error {
    // Debit EVM balance
    stateDB.SubBalance(caller, amount)

    // THIS IS THE BUG: Commit() during execution
    stateDB.Commit()  // ← Bypasses journal/snapshot mechanism

    // Create Cosmos delegation
    p.stakingKeeper.Delegate(ctx, delegatorAddr, amount, ...)
    return nil
}
Enter fullscreen mode Exit fullscreen mode

Class 2: The ICS20 Recursive State Injection — SagaEVM's $7M Hack

Affected: SagaEVM (exploited January 2026, $7M stolen)
Severity: Critical — unbacked stablecoin minting
Advisory: ASA-2026-002

The Bug

The ICS20 precompile handles cross-chain token transfers via IBC. When processing an incoming IBC transfer, the precompile must:

  1. Verify the IBC packet proof
  2. Mint or unlock tokens on the receiving chain
  3. Update the EVM state to reflect the new balance

SagaEVM's implementation had a flaw in nested EVM execution paths: state updates made in recursive calls were not properly reflected in the outer execution context. This meant the same token balance could be "used" multiple times within a single transaction.

The Exploit Flow

1. Attacker deploys contract on SagaEVM
2. Contract crafts a message that triggers the ICS20 precompile
3. The precompile enters a nested execution path
4. Inside the nested context, state updates (token minting) occur
5. BUT the outer context doesn't see these updates
6. The same "incoming transfer" proof is effectively replayed
7. Each nested call mints fresh Saga Dollars without collateral
8. Attacker exits with millions in unbacked $D stablecoins
9. Swaps $D for real assets (yETH, yUSD, tBTC)
10. Bridges everything to Ethereum → Tornado Cash
Enter fullscreen mode Exit fullscreen mode

The Deeper Problem

This isn't just a SagaEVM bug. Cosmos Labs confirmed the vulnerability existed in Ethermint's original codebase, meaning every EVM chain built on Ethermint was potentially affected. The vulnerability is a consequence of how the EVM's call stack interacts with Cosmos's CacheMultiStore:

// Simplified: The problematic state isolation pattern
func (p *ICS20Precompile) Transfer(evm *vm.EVM, ...) ([]byte, error) {
    // Creates a new cached context for this precompile call
    cacheCtx, writeFn := ctx.CacheContext()

    // Execute IBC transfer logic in cached context
    err := p.transferKeeper.OnRecvPacket(cacheCtx, packet)

    // If the EVM call that invoked us gets reverted,
    // writeFn is never called... but nested precompile calls
    // within this context may have already committed their state
    // to the Cosmos KVStore independently

    writeFn() // Commit cached state
    return result, nil
}
Enter fullscreen mode Exit fullscreen mode

The key insight: Cosmos's CacheMultiStore and the EVM's StateDB have independent commit boundaries. A revert in one doesn't automatically revert the other, and nested execution contexts amplify this mismatch.


Class 3: The Gas-Limited Partial Write — Claiming Rewards Forever

Affected: All evmOS/Cosmos EVM chains using stateful precompiles
Severity: High — funds theft + chain halt
Advisory: GHSA-mjfq-3qr2-6g84

The Bug

EVM precompile calls consume gas just like regular contract calls. But what happens when a precompile runs out of gas halfway through its state modifications?

In standard Solidity, running out of gas reverts all state changes in that execution context. But Cosmos EVM precompiles write to the Cosmos KVStore during execution, not at the end. If gas runs out after the Cosmos state write but before the precompile returns, you get a partial state write.

The Exploit: Infinite Reward Claims

Consider the distribution precompile's ClaimRewards function:

// VULNERABLE: Non-atomic precompile execution
func (p *DistributionPrecompile) ClaimRewards(ctx sdk.Context, ...) ([]byte, error) {
    // Step 1: Transfer rewards from module to user (Cosmos Bank module)
    err := p.bankKeeper.SendCoins(ctx, moduleAddr, userAddr, rewards)
    // ← At this point, rewards have been transferred in Cosmos state

    // Step 2: Reset claimable rewards to zero
    p.distributionKeeper.SetDelegatorRewards(ctx, userAddr, sdk.Coins{})
    // ← If gas runs out HERE, rewards are transferred but NOT reset

    // Step 3: Update EVM state, emit events, etc.
    // ... more gas-consuming operations ...

    return bz, nil
}
Enter fullscreen mode Exit fullscreen mode

The attack:

1. Accumulate staking rewards normally
2. Call ClaimRewards with PRECISELY enough gas to complete Step 1
   but NOT enough for Step 2
3. Rewards are transferred (Cosmos state committed)
4. Claimable balance is NOT reset (ran out of gas before Step 2)
5. EVM call reverts due to OOG, but Cosmos state persists
6. Repeat: claim the same rewards over and over
Enter fullscreen mode Exit fullscreen mode

Even worse, this gas-manipulation technique can cause non-deterministic execution across validators (different gas metering implementations may fail at different points), leading to consensus failures and chain halts.


The Fix: Atomic Precompile Execution

The patch for all three vulnerability classes converges on a single principle: precompile state changes must be atomic across both state machines.

Here's the concrete implementation from the Cosmos EVM security advisory:

1. Snapshot the Cosmos Multi-Store Before Execution

type snapshot struct {
    MultiStore storetypes.CacheMultiStore
    Events     sdk.Events
}

func (p Precompile) RunSetup(...) (..., snapshot, ...) {
    cms := ctx.MultiStore().(storetypes.CacheMultiStore)
    snap := snapshot{
        MultiStore: cms.CacheMultiStore(), // Deep copy
        Events:     ctx.EventManager().Events(),
    }
    return ctx, stateDB, snap, method, initialGas, args, nil
}
Enter fullscreen mode Exit fullscreen mode

2. Wrap Every Precompile in RunAtomic

func (p Precompile) RunAtomic(
    s snapshot, 
    stateDB *statedb.StateDB, 
    fn func() ([]byte, error),
) ([]byte, error) {
    bz, err := fn()
    if err != nil {
        // ROLLBACK: Restore Cosmos state to pre-execution snapshot
        stateDB.RevertMultiStore(s.MultiStore, s.Events)
    }
    return bz, err
}
Enter fullscreen mode Exit fullscreen mode

3. Handle Gas Errors Explicitly

func HandleGasError(ctx sdk.Context, contract *vm.Contract, 
    initialGas storetypes.Gas, err *error, 
    stateDB *statedb.StateDB, snapshot snapshot) func() {
    return func() {
        if r := recover(); r != nil {
            switch r.(type) {
            case storetypes.ErrorOutOfGas:
                // On OOG: revert BOTH EVM and Cosmos state
                stateDB.RevertMultiStore(snapshot.MultiStore, snapshot.Events)
                *err = vm.ErrOutOfGas
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The Key Insight

The RevertMultiStore function replaces the current Cosmos CacheMultiStore with the snapshot taken before execution began:

func (s *StateDB) RevertMultiStore(cms storetypes.CacheMultiStore, events sdk.Events) {
    s.cacheCtx = s.cacheCtx.WithMultiStore(cms)
    s.writeCache = func() {
        s.ctx.EventManager().EmitEvents(events)
        cms.Write()
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures that if anything goes wrong — revert, out-of-gas, or nested execution anomaly — the Cosmos state rolls back to exactly where it was before the precompile began.


Your Cosmos EVM Security Checklist

If you're building on or auditing a Cosmos EVM chain, check for these:

1. No Mid-Execution Commits

grep -r "StateDB.Commit()" x/evm/precompiles/
# Should return ZERO results during precompile execution
Enter fullscreen mode Exit fullscreen mode

2. Atomic Precompile Wrapper Present

Every stateful precompile's Run function should use RunAtomic:

grep -r "RunAtomic" precompiles/
# Every precompile directory should have matches
Enter fullscreen mode Exit fullscreen mode

3. Gas Error Handling Reverts Cosmos State

grep -r "RevertMultiStore" x/evm/
# Should appear in both HandleGasError and RunAtomic
Enter fullscreen mode Exit fullscreen mode

4. No Nested Precompile Calls Without Isolation

Audit any precompile that calls another precompile or re-enters the EVM. Each nested call must have its own snapshot/rollback boundary.

5. Forked Code Audit Trail

If your chain forked Ethermint/evmOS:

  • Document the exact commit hash you forked from
  • Track all upstream security advisories (ASA-2026-xxx series)
  • Diff your precompile modifications against upstream patches
  • Run the upstream test suite against your fork

The Bigger Lesson: Dual-State Machines Are Hard

These vulnerabilities share a common ancestor: the assumption that two independent state machines can be coordinated within a single transaction boundary using ad-hoc synchronization.

The EVM was designed with one state machine, one journal, one commit. Cosmos SDK was designed with one KVStore, one cache context, one commit. Bolting them together creates a system where:

  • Reverts in one don't propagate to the other
  • Gas exhaustion creates asymmetric partial states
  • Nested execution contexts multiply the possible desync points

The atomic precompile wrapper is a necessary patch, but it's treating symptoms. The fundamental architectural tension remains, and future vulnerability classes in this seam are virtually guaranteed.

If you're building a Cosmos EVM chain: treat every precompile as a bridge between hostile state machines, not a convenience function. Audit accordingly.


This analysis draws on security advisories from Cosmos Labs, Asymmetric Research, Halborn, and the Evmos/evmOS security teams. All vulnerabilities discussed have been patched in upstream repositories.

References:

Top comments (0)