Skip to content

fix: F-2026-18182 | [Dual Defense] Unbounded UEA Payload Gas Can Stall Synchronous EVM Execution - #49

Merged
0xNilesh merged 3 commits into
audit-fixesfrom
F-2026-18182
Aug 26, 2026
Merged

0xNilesh merged 3 commits into
audit-fixesfrom
F-2026-18182

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

F-2026-18182 — Unbounded UEA Payload Gas Can Stall Synchronous EVM Execution

Severity: Low (Impact 1 / Likelihood 1) · Scope: recommendation 1 only.

This PR is stacked on #43 (F-2026-18824) and
targets F-2026-18824, not audit-fixes. Both changes edit the same if gasLimit != nil block in
DerivedEVMCallWithData, so stacking keeps the review honest. Merge #43 first; GitHub will then
retarget this PR to audit-fixes and the diff shown here becomes the whole change.

What was already fixed, and is not redone here

The bulk of this finding — Hacken's recommendations 2 and 3, a protocol cap on payload gas — is
already delivered by #43, which clamps the caller-supplied budget in
x/vm/keeper/call_evm.go:

gasCap = min(gasLimit.Uint64(), config.DefaultGasCap)   // DefaultGasCap = 25_000_000

25M is the confirmed intended limit. This PR does not add a second cap, change DefaultGasCap,
or touch that clamp.

The remaining gap — recommendation 1

gasLimit.Uint64() truncates silently. big.Int.Uint64 is documented as undefined for values
that do not fit; in practice it returns the low 64 bits.

Node-side, push-chain-node#335
(F-2026-18798) bounds UniversalPayload.GasLimit to uint256 (BitLen() <= 256) — so everything from
2^64 up to 2^256 passes validation and then reaches this call. A payload asking for 2^64 + 5
therefore received 5 gas and failed with a confusing out-of-gas error instead of a clear
rejection.

This is not dangerous: truncation only ever shrinks the value, and the min clamps whatever
survives. But the semantics are wrong and the error misleads whoever has to debug it. A value can
also truncate upward into a plausible budget — 2^64 + 5_000_000 truncates to exactly 5,000,000
and would execute cleanly against a budget the caller never asked for.

The fix

Guard with IsUint64() before calling .Uint64(), and reject with an error that names the problem:

if !gasLimit.IsUint64() {
    return nil, errorsmod.Wrapf(
        types.ErrInvalidGasLimit,
        "gas limit %s does not fit in uint64 (max %d)",
        gasLimit, uint64(math.MaxUint64),
    )
}

IsUint64 also rejects a negative limit, for which Uint64 is equally undefined.

math.MaxUint64 exactly still fits, is still accepted, and is still clamped to DefaultGasCap by
#43's min — the guard is invisible to every value that was ever meaningful.

CallEVMWithData — checked, out of scope

On this base CallEVMWithData hard-codes GasLimit: config.DefaultGasCap and never calls .Uint64()
on the caller's gasCap, so it cannot truncate. The parameter it ignores is F-2026-18818, fixed
separately in #46 — and that PR already
carries its own gasCap.BitLen() <= 64 guard on the newly-wired value. So there is no second site to
fix, on this base or after #46 lands. A grep of the fork confirms
x/vm/keeper/call_evm.go:223 is the only .Uint64() on a gas value outside tests.

Note the two guards deliberately differ: #46 falls back to the default ceiling, which is fine for
internal keeper callers; this path rejects, because the value is user-controlled and a silent
substitution would hide a malformed payload.

Declined recommendations

# Rec Call
4 Reserve/consume block gas before entering the EVM Declined — a deep execution-path change for an Impact-1 finding, and moot at max_gas: -1, which is what donut runs
5 Up-front UEA balance check against max payable gas Declined — unnecessary once the budget is bounded at 25M by #43
6 Reconsider the gasless allowlist for MsgExecutePayload Declined — gasless UEA execution is the product

Live traffic

Scanning 600 donut blocks from height 22,075,183: the client declares 50,000,000 gas on every
payload, but observed gasUsed is 610,477 (two samples, identical) — about 40x below the
25M cap. Neither #43's cap nor this guard touches real traffic; the guard only fires on values that
were never executable in the first place.

Tests

Added to tests/integration/x/vm/test_derived_call.go, reusing #43's deployRawCode /
gasBurnerSuccessCode / invalidOpcodeCode fixtures:

  • TestDerivedEVMCallWithDataRejectsNonUint64GasLimit — table over 2^64, 2^64 + 5,
    2^64 + 5_000_000 and 2^256 - 1. Each must return ErrInvalidGasLimit, produce no execution
    result, and leave the parent gas meter essentially untouched. The +5_000_000 and 2^256 - 1
    cases are the load-bearing ones: they truncate into budgets large enough for the ~2.2M-gas burner
    to run to completion, so a regression shows up as a successful call rather than a different error.
  • TestDerivedEVMCallWithDataAcceptsMaxUint64GasLimit — the boundary: math.MaxUint64 fits, is not
    rejected, and comes back clamped to DefaultGasCap (asserted via an INVALID opcode, which burns
    the whole frame so res.GasUsed reports the effective cap).
  • TestDerivedEVMCallWithDataNormalGasLimitUnaffected — the live donut value, 610,477, still works.
  • TestDerivedEVMCallWithDataNilGasLimitUsesEstimation — a nil limit still takes the estimation
    path and never reaches the guard.

Mutation-verified. With the IsUint64 guard disabled, all four subtests fail, in the two
distinct shapes the finding describes:

--- FAIL: .../RejectsNonUint64GasLimit/2^64
    Error: Error "apply message: intrinsic gas too low" does not contain "does not fit in uint64"
--- FAIL: .../RejectsNonUint64GasLimit/2^64_+_5
    Error: Error "apply message: intrinsic gas too low" does not contain "does not fit in uint64"
--- FAIL: .../RejectsNonUint64GasLimit/2^64_+_5_000_000_(truncation_would_execute_successfully)
    Error: "2244209" is not less than "100000"
    Messages: a rejected gas limit must not run the EVM; truncating to 5000000 would have burned ~2.2M
--- FAIL: .../RejectsNonUint64GasLimit/2^256_-_1_(the_largest_value_node-side_validation_admits)
    Error: "2244209" is not less than "100000"
    Messages: a rejected gas limit must not run the EVM; truncating to 18446744073709551615 would have burned ~2.2M

The first two show the misleading intrinsic gas too low; the last two show 2,244,209 gas actually
burned
against a budget the caller never requested. With the guard restored, all four pass.

Test run

evmd's TestKeeperTestSuite passes apart from TestRefundGas/Case_invalid_GasPrice_in_message,
which fails identically on clean audit-fixes and is unrelated to this change. go build ./... is
clean. Two go test ./x/vm/... unit failures (TestExtendedEips in x/vm/types,
TestMintAmountToAccount in x/vm/wrappers) are likewise pre-existing: both packages are
byte-identical to origin/audit-fixes on this branch and neither imports x/vm/keeper.

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.
@github-actions github-actions Bot added the tests label Aug 26, 2026
@0xNilesh
0xNilesh changed the base branch from F-2026-18824 to audit-fixes August 26, 2026 14:20
Keeps this branch's IsUint64 rejection and takes the updated
DefaultGasCap comment from audit-fixes (#46 made the cap
caller-narrowable).
@0xNilesh
0xNilesh merged commit a726345 into audit-fixes Aug 26, 2026
10 of 19 checks passed
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