Skip to content

fix: F-2026-18824 | [Dual Defense] Failed DerivedEVMCallWithData / CallEVMWithData Skip Cosmos ConsumeGas - #43

Open
0xNilesh wants to merge 1 commit into
audit-fixesfrom
F-2026-18824
Open

fix: F-2026-18824 | [Dual Defense] Failed DerivedEVMCallWithData / CallEVMWithData Skip Cosmos ConsumeGas#43
0xNilesh wants to merge 1 commit into
audit-fixesfrom
F-2026-18824

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

The defect

x/vm/keeper/call_evm.go charged the parent Cosmos gas meter only on success. Both internal-call entry points returned on res.Failed() before reaching ctx.GasMeter().ConsumeGas(res.GasUsed):

  • CallEVMWithDataif res.Failed() { return ... } sat above ConsumeGas(res.GasUsed)
  • DerivedEVMCallWithData — same shape

So EVM work that reverted, or that deliberately ran itself out of gas, cost the enclosing Cosmos transaction nothing beyond the incidental KV-store gas the call happened to draw through the shared meter. A live PoC on an earlier build ran a ~52M-gas burner that looped and then reverted; the enclosing tx was charged 26,021 gas.

The gasless flag is unrelated to this: it only zeroes the TxGasUsed event attribute, never the meter. Successful gasless derived calls already consumed parent gas — the asymmetry was failure-only.

Why the clamp is a prerequisite, not a bonus

On an out-of-gas / exceptional halt, res.GasUsed equals the gas cap. The two functions were bounded very differently:

  • CallEVMWithData builds its message with GasLimit: config.DefaultGasCaphardcoded. res.GasUsed is already bounded, so charging on failure is safe as-is and needs no clamp here.
  • DerivedEVMCallWithData did gasCap := config.DefaultGasCap; if gasLimit != nil { gasCap = gasLimit.Uint64() } — a caller-supplied gasLimit overrode the default with no clamp.

Charging res.GasUsed on the derived failure path without clamping would therefore have been a worse bug than the one being fixed: a caller sets UniversalPayload.GasLimit arbitrarily high, forces an out-of-gas halt, and ConsumeGas(huge) panics OutOfGas on the parent meter — aborting the whole Cosmos tx, not just the EVM call (CacheContext() branches the multistore but shares the gas meter, so the panic is not contained). That path runs inside MsgVoteInbound, so an abort there is a lost validator vote. This is exactly why an earlier change (a05ad85a) removed the failure-path charge rather than fixing it.

So: clamp first, then charge.

What changed

x/vm/keeper/call_evm.go:

  1. DerivedEVMCallWithData — the caller-supplied gas limit is clamped: gasCap = min(gasLimit.Uint64(), config.DefaultGasCap).
  2. DerivedEVMCallWithData — the res.Failed() branch now calls ctx.GasMeter().ConsumeGas(res.GasUsed, "apply evm message (failed)") before returning the error.
  3. CallEVMWithData — same failure-path ConsumeGas. No clamp needed (see above); its gasCap parameter stays ignored, which is tracked separately as F-2026-18818.
  4. The stale comment on CallEVMWithData's cache context ("leave the parent meter untouched") is corrected — the cache exists to discard state, and shares the gas meter either way.

res.GasUsed is charged — not GasMeter().Limit(), which is what upstream's ResetGasMeterAndConsumeGas(ctx, ctx.GasMeter().Limit()) did and which would burn the caller's entire remaining budget on any revert.

The clamp does not narrow any real caller

Every other path into DerivedEVMCallWithData was already ceilinged at DefaultGasCap (25M): the gasLimit == nil branch uses the constant directly, and the estimating branch runs EstimateGasInternal with GasCap: config.DefaultGasCap. On the chain side, exactly one caller passes a non-nil gasLimitCallUEAExecutePayload, forwarding the attacker-controlled UniversalPayload.GasLimit. Every other DerivedEVMCall in x/uexecutor and x/ucallback passes nil. The clamp therefore only ever narrows an outlier.

Tests

tests/integration/x/vm/test_derived_call.go and test_call_evm.go (run via evmd/tests/integration, TestKeeperTestSuite). They install raw runtime bytecode at a fresh address: a ~2.2M-gas memory-expansion burner ending in REVERT, the same burner ending in STOP, and a bare INVALID opcode (an exceptional halt burns the entire frame, so res.GasUsed == gasCap — an out-of-gas shape without waiting on a 25M-gas loop). The parent meter is a real bounded storetypes.NewGasMeter(60_000_000).

  • TestDerivedEVMCallWithDataFailedCallChargesParentGas — a reverting derived call charges res.GasUsed (>2M) to the parent meter, once, not twice.
  • TestDerivedEVMCallWithDataGaslessFailureChargesParentGasgasless: true does not exempt the meter.
  • TestDerivedEVMCallWithDataClampsCallerGasLimit — caller asks for 10 × DefaultGasCap and halts; asserts no panic, res.GasUsed == DefaultGasCap, and the parent meter absorbs at most the clamped cap.
  • TestDerivedEVMCallWithDataSuccessChargesGasUsed — happy path unchanged: res.GasUsed charged exactly once.
  • TestCallEVMWithDataFailedCallChargesParentGas / TestCallEVMWithDataSuccessChargesGasUsed — the same failure/success coverage for CallEVMWithData.

Regression-detector check — each half of the fix was reverted in turn and the suite re-run:

mutation result
drop the derived failure-path ConsumeGas the 3 derived failure tests FAIL; success + CallEVMWithData tests still pass
keep the charge, drop the clamp only ...ClampsCallerGasLimit FAILS with should not panic — reproducing exactly the OutOfGas abort the clamp prevents
drop the CallEVMWithData failure-path ConsumeGas TestCallEVMWithDataFailedCallChargesParentGas FAILS alone

The fix was restored and the suite is green. ./x/vm/... passes. The one red test in the wider TestKeeperTestSuite, TestRefundGas/Case_invalid_GasPrice_in_message, fails identically on audit-fixes without this change.

Related, tracked separately

  • F-2026-18182 — chain-side clamp on UniversalPayload.GasLimit. Worth landing: after this PR a failing derived call can still charge up to DefaultGasCap (25M) to the enclosing tx, which is correct metering but is a large bill for a MsgVoteInbound whose own gas limit may be lower. A sane per-message budget on the chain side bounds that properly.
  • F-2026-18818CallEVMWithData ignores its gasCap parameter (x/ibc/callbacks passes remainingGas and it is silently dropped). Not touched here: honouring it unclamped would reintroduce the same unbounded-charge problem this PR exists to avoid, so it needs its own min(gasCap, DefaultGasCap) treatment.

CallEVMWithData and DerivedEVMCallWithData returned on res.Failed() before
reaching ctx.GasMeter().ConsumeGas(res.GasUsed), so a reverting or deliberately
out-of-gas internal call was free at the Cosmos meter.

DerivedEVMCallWithData also clamps a caller-supplied gasLimit to
config.DefaultGasCap: on an out-of-gas halt res.GasUsed equals the cap, so
without the clamp the caller would choose how much gas the enclosing Cosmos tx
is forced to consume and could panic it with OutOfGas.
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