Skip to content

fix: F-2026-18818 | [Dual Defense] CallEVMWithData Ignores gasCap — Callback Gas Sandbox Broken - #46

Merged
0xNilesh merged 2 commits into
audit-fixesfrom
F-2026-18818
Aug 26, 2026
Merged

0xNilesh merged 2 commits into
audit-fixesfrom
F-2026-18818

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

Finding

CallEVMWithData accepts a gasCap *big.Int and never reads it:

msg := core.Message{
    ...
    GasLimit:   config.DefaultGasCap,   // 25_000_000, always
    ...
}

Four call sites pass a real one, all silently ignored — x/ibc/callbacks/keeper/keeper.go:192, 215, 321, 414. The intent there is unambiguous: they hand over the remaining Cosmos gas to sandbox the callback's EVM work. DerivedEVMCallWithData honours its gasLimit; this helper did not.

There is no backstop either. Those paths install NewInfiniteGasMeterWithLimit(cbData.CommitGasLimit), but infiniteGasMeterWithLimit.ConsumeGas only panics on uint64 overflow — never on exceeding its own limit — so IsOutOfGas() is permanently false. EVM execution could therefore finish having used up to DefaultGasCap, and the later ctx.GasMeter().ConsumeGas(res.GasUsed) onto ibc-go's basic ProcessCallback meter could then panic ErrorOutOfGas.

Net effect: up to ~25M gas of EVM work per callback while Cosmos charges at most CommitGasLimit (capped at 1M) — CPU griefing against validators and relayers.

Changes

CallEVMWithData honours gasCap, clamped. Ported from upstream/main, which already carries this fix, so the fork stays close to upstream:

gasLimit := config.DefaultGasCap
if gasCap != nil && gasCap.Sign() > 0 {
    if gasCap.BitLen() <= 64 {
        if provided := gasCap.Uint64(); provided < gasLimit {
            gasLimit = provided
        }
    }
}

DefaultGasCap stays the ceiling — a caller can only ever narrow the limit, never widen it. A non-positive cap, or one wider than uint64, falls back to the default rather than truncating to something meaningless.

Timeout callback now runs on cachedCtx. keeper.go:414 used the parent ctx while reading its gas cap from cachedCtx on the very next expression. The acknowledgement path uses cachedCtx and says so in a comment; this was a copy-paste divergence that committed the callback's EVM state regardless of outcome and made the trailing writeFn() a no-op. Its ConsumeGas descriptor also said onPacketAcknowledgement inside the timeout handler; corrected.

Tests

tests/integration/x/vm/test_call_evm_gascap.go — new file, deliberately separate from test_call_evm.go so it does not collide with PR #43.

The load-bearing assertion is that a cap below the call's cost stops the call. Asserting only that a generous cap succeeds would pass just as happily against the ignored parameter.

Mutation-verified — GasLimit reverted to the hardcoded default, test re-run, fix restored:

mutation result
ignore gasCap again FAILS: "a gasCap below the call's cost must stop the call"

One trap recorded in the test comments for whoever touches it next: res.GasUsed is not a viable gas limit. It is the net, post-refund figure (~24k for this call), while the execution needs materially more than that up front — a cap of 2× GasUsed still reverts, 10× succeeds. Do not "tighten" the headroom case to baseline or a small multiple.

Hacken's remediations

  1. Set GasLimit from gasCap, clamped — done.
  2. Enforce CommitGasLimit inside InfiniteGasMeterWithLimit — declined. That meter is also installed by x/vm/keeper/grpc_query.go:899 for eth_call / estimateGas; making it enforce would change query semantics well beyond this finding. Worth its own ticket. (Related latent bug spotted while reading it: GasConsumedToLimit() returns raw consumed, contradicting its own doc comment, which says it caps at the limit.)
  3. Cap post-call ConsumeGas at min(GasUsed, remaining) — largely subsumed. With rec 1 in place res.GasUsed is bounded by CommitGasLimit, so the charge can only overrun when ExecutionGasLimit < CommitGasLimit — precisely the case where ibc-go's AllowRetry() is true and re-panicking is the intended behaviour.
  4. Align timeout CallEVM to cachedCtx — done.
  5. Regression test — done, mutation-verified.

Scope note

The evm x/ibc/callbacks keeper is not instantiated on Push. app.go:1007 wires ibccallbacks.NewIBCMiddleware(transferStack, app.PacketForwardKeeper, wasmStack, maxCallbackGas), where the ContractKeeper is wasm.NewIBCHandler(...), and every node-side CallEVM/CallEVMWithData call passes nil. So on Push this is currently inert: it removes a live footgun and aligns with upstream rather than closing a reachable hole.

Pre-existing failures (verified against clean audit-fixes, not caused here)

  • x/vm/types — TestExtendedEips
  • x/vm/wrappers — TestMintAmountToAccount
  • integration — TestKeeperTestSuite/TestRefundGas/Case_invalid_GasPrice_in_message

Each reproduces identically with this branch's changes stashed.

gofmt reports x/vm/keeper/call_evm.go as misaligned; that is pre-existing on base, in DerivedEVMCallWithData's message literal, and left alone to keep the diff focused.

The parameter was accepted and ignored, so callers sandboxing EVM work got a
25M gas message regardless. Also runs the timeout callback on cachedCtx, as
the ack path already does.
Keeps #43's failure-path ConsumeGas alongside this branch's gasCap
plumbing; updates the stale 'hardcoded DefaultGasCap' comment since a
caller-supplied gasCap can now narrow it.
@0xNilesh
0xNilesh merged commit 0076392 into audit-fixes Aug 26, 2026
8 of 19 checks passed
0xNilesh added a commit that referenced this pull request Aug 26, 2026
Keeps this branch's IsUint64 rejection and takes the updated
DefaultGasCap comment from audit-fixes (#46 made the cap
caller-narrowable).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant