Skip to content

fix(statedb): revert precompileCallsCounter on journal revert#1094

Open
sucloudflare wants to merge 1 commit intocosmos:mainfrom
sucloudflare:fix/precompile-calls-counter-revert
Open

fix(statedb): revert precompileCallsCounter on journal revert#1094
sucloudflare wants to merge 1 commit intocosmos:mainfrom
sucloudflare:fix/precompile-calls-counter-revert

Conversation

@sucloudflare
Copy link
Copy Markdown

fix: revert precompileCallsCounter on journal revert

Problem

When a precompile call is reverted (e.g. via RevertToSnapshot), the precompileCallsCounter in StateDB is not decremented. Because the counter is only incremented on entry and never rolled back on revert, an attacker can craft a transaction that deliberately triggers and reverts precompile calls in a tight loop, driving the counter up to the per-tx limit without any of those calls actually succeeding.

Once the limit is hit, all subsequent legitimate precompile calls within the same transaction fail unconditionally. This is a deterministic, transaction-level griefing vector that breaks any contract that relies on precompiles after an internal revert.

Affected file: x/vm/statedb/journal.goprecompileCallChange.Revert

Minimal reproduction (Solidity pseudo-code)

// Attacker contract
function exploit(IPrecompile pre, uint limit) external {
    for (uint i = 0; i < limit; i++) {
        try pre.someCall{gas: 1}() {} catch {}  // reverts, but counter++
    }
    // Now the limit is exhausted.
    // Any precompile call here will fail even if gas is sufficient.
    pre.legitimateCall();  // ← reverts with "precompile call limit reached"
}

Fix

Decrement precompileCallsCounter inside precompileCallChange.Revert so that a reverted call gives back its slot.

// journal.go
func (pc precompileCallChange) Revert(s *StateDB) {
    s.RevertMultiStore(pc.multiStore, pc.events)
    // FIX: restore the counter so reverted calls don't consume the per-tx limit.
    if s.precompileCallsCounter > 0 {
        s.precompileCallsCounter--
    }
}

This mirrors the invariant maintained by other journal entries (e.g. balance changes), where every mutation recorded in the journal is fully undone on revert.

Tests

Two new unit tests are added in x/vm/statedb/journal_counter_revert_test.go:

  • TestPrecompileCallsCounterRevertedOnRevert — asserts that a single reverted precompile call decrements the counter.
  • TestAttackerCannotExhaustPrecompileLimitViaReverts — asserts that reverting N calls in a loop leaves the counter at 0, so the next valid call is still allowed.

Impact / Severity rationale

Although this is not a chain-wide DoS, the impact is concrete:

Aspect Detail
Exploitability Deterministic; no luck or race condition required
Target Any contract that calls a precompile after an internal revert
Effect All precompile calls in the remainder of the tx silently fail
Attacker cost Single tx, minimal gas (reverted calls spend very little)

We believe Low–Medium severity is appropriate: the exploit is reliable and breaks expected execution flow for affected contracts, even though it is confined to a single transaction.

Checklist

  • Focused, minimal change — only Revert is modified
  • No new exported API surface
  • Unit tests covering both the fix and the attack scenario
  • CHANGELOG entry added (see below)

CHANGELOG

### Bug Fixes
- [#XXX](https://github.com/cosmos/evm/pull/XXX) Fix `precompileCallsCounter`
  not being decremented on journal revert, preventing transaction-level
  griefing where reverted precompile calls could exhaust the per-tx limit.

fix: revert precompileCallsCounter on journal revert

Problem

When a precompile call is reverted (e.g. via RevertToSnapshot), the precompileCallsCounter in StateDB is not decremented. Because the counter is only incremented on entry and never rolled back on revert, an attacker can craft a transaction that deliberately triggers and reverts precompile calls in a tight loop, driving the counter up to the per-tx limit without any of those calls actually succeeding.

Once the limit is hit, all subsequent legitimate precompile calls within the same transaction fail unconditionally. This is a deterministic, transaction-level griefing vector that breaks any contract that relies on precompiles after an internal revert.

Affected file: x/vm/statedb/journal.goprecompileCallChange.Revert

Minimal reproduction (Solidity pseudo-code)

// Attacker contract
function exploit(IPrecompile pre, uint limit) external {
    for (uint i = 0; i < limit; i++) {
        try pre.someCall{gas: 1}() {} catch {}  // reverts, but counter++
    }
    // Now the limit is exhausted.
    // Any precompile call here will fail even if gas is sufficient.
    pre.legitimateCall();  // ← reverts with "precompile call limit reached"
}

Fix

Decrement precompileCallsCounter inside precompileCallChange.Revert so that a reverted call gives back its slot.

// journal.go
func (pc precompileCallChange) Revert(s *StateDB) {
    s.RevertMultiStore(pc.multiStore, pc.events)
    // FIX: restore the counter so reverted calls don't consume the per-tx limit.
    if s.precompileCallsCounter > 0 {
        s.precompileCallsCounter--
    }
}

This mirrors the invariant maintained by other journal entries (e.g. balance changes), where every mutation recorded in the journal is fully undone on revert.

Tests

Two new unit tests are added in x/vm/statedb/journal_counter_revert_test.go:

  • TestPrecompileCallsCounterRevertedOnRevert — asserts that a single reverted precompile call decrements the counter.
  • TestAttackerCannotExhaustPrecompileLimitViaReverts — asserts that reverting N calls in a loop leaves the counter at 0, so the next valid call is still allowed.

Impact / Severity rationale

Although this is not a chain-wide DoS, the impact is concrete:

Aspect Detail
Exploitability Deterministic; no luck or race condition required
Target Any contract that calls a precompile after an internal revert
Effect All precompile calls in the remainder of the tx silently fail
Attacker cost Single tx, minimal gas (reverted calls spend very little)

We believe Low–Medium severity is appropriate: the exploit is reliable and breaks expected execution flow for affected contracts, even though it is confined to a single transaction.

Checklist

  • Focused, minimal change — only Revert is modified
  • No new exported API surface
  • Unit tests covering both the fix and the attack scenario
  • CHANGELOG entry added (see below)

CHANGELOG

### Bug Fixes
- [#XXX](https://github.com/cosmos/evm/pull/XXX) Fix `precompileCallsCounter`
  not being decremented on journal revert, preventing transaction-level
  griefing where reverted precompile calls could exhaust the per-tx limit.

@sucloudflare sucloudflare force-pushed the fix/precompile-calls-counter-revert branch from 5a6299f to 04aabad Compare March 30, 2026 15:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant