Skip to content

fix: F-2026-18144 | [Dual Defense] Unchecked Cumulative GasWanted Can Fail FinalizeBlock Under Unbounded Block Gas - #350

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

fix: F-2026-18144 | [Dual Defense] Unchecked Cumulative GasWanted Can Fail FinalizeBlock Under Unbounded Block Gas#350
0xNilesh merged 3 commits into
audit-fixesfrom
F-2026-18144

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

Issue

Three individually-fine things collide:

// ante/types/block.go (evm)  — max_gas: -1 becomes no limit at all
if maxGas == -1 { return math.MaxUint64 }

// ante/evm/10_gas_wanted.go  — the ONLY per-tx bound, inert at MaxUint64
if gasWanted > blockGasLimit { ...reject... }

// x/feemarket/keeper/keeper.go — unchecked uint64 accumulation
result := k.GetTransientGasWanted(ctx) + gasWanted

// x/feemarket/keeper/abci.go — EndBlock ERRORS rather than clamps
if !gasWanted.IsInt64() { ...; return err }

Donut runs max_gas: -1 (in-tree update_max_block_gas.json). Two transactions each declaring
MaxInt64 sum into (MaxInt64, MaxUint64] — a valid uint64 that cannot convert to int64, with
neither transaction individually rejectable. The error comes out of FinalizeBlock, after the
block is decided
: CometBFT at height H, application stuck at H−1.

Gasless transactions are the whole attack surface. app/cosmos/min_gas_price.go derives the
required fee from the declared gas — fee = ceil(minGasPrice * gasLimit) — so a fee-paying tx
declaring MaxInt64 owes roughly 4.6e36 upc at donut's parameters and never gets past the ante.
Fee-exempt messages pay nothing, so nothing bounds what they declare. The defence therefore has to
be a hard cap, not a price.

Live donut measurement, 400 blocks from height 22,076,325, 133 txs with gas_wanted > 0:

gas_wanted  min=21,000  med=500,000,000  max=500,000,000
gas_used    min=21,000  med=1,934,957    max=5,011,877
  => the largest real consumer used 1.0024% of what it declared

Every universal-validator vote arrives as an authz.MsgExec declaring the hardcoded 500,000,000
from universalClient/pushsigner/vote.go.

Change

A governance-controlled cap on the gas a fee-exempt transaction may declare.

piece file
max_gasless_tx_gas param, default 100,000,000 proto/uexecutor/v1/types.proto, x/uexecutor/types/params.go
GaslessGasLimitDecorator — rejects a gasless tx declaring more app/ante/gasless_gas_limit.go
wired before NewGasWantedDecorator, right after SetUpContext app/ante/ante_cosmos.go
UexecutorKeeper on HandlerOptions (+ Validate()) app/ante/handler_options.go, app/app.go
UpdateParams validates before writing; GetParams accessor x/uexecutor/keeper/msg_update_params.go
universal validator declares 100,000,000, not 500,000,000 universalClient/pushsigner/vote.go

Why 100,000,000: ~20x the observed peak gas_used of 5,011,877, and a 5x reduction from
today's declaration. Overflow stays unreachable — 100M x ~1e5 txs/21MB block ~= 1e13 against
MaxInt64 ~= 9.2e18, six orders of magnitude of headroom.

Why a parameter and not a constant: the failure mode of "too low" is universal validators
unable to vote. That has to be fixable by proposal in minutes, not by a binary release.

Zero can never mean "no cap". ValidateBasic rejects max_gasless_tx_gas == 0 at genesis and
on every MsgUpdateParams, and the decorator falls back to the 100,000,000 default when the
parameter is unreadable or unset. A missing parameter fails safe, not open.

Where it sits. The cap hangs off the existing txpolicy.IsGaslessTx(tx) predicate — the same
one used by app/ante/fee.go, app/cosmos/min_gas_price.go and
app/ante/account_init_decorator.go — so it inherits PR #332's empty-MsgExec fix and cannot be
dodged by that vacuous-truth trick. It runs immediately after SetUpContextDecorator so the store
read is metered, and well before NewGasWantedDecorator, which is what performs the accumulation.

audit-fixes is fresh-genesis, so the new parameter needs no upgrade handler or state migration —
it ships in DefaultParams().

Rollout ordering

The universal validators hardcoded 500,000,000. A 100,000,000 cap rejects every vote until the
fleet declares less, which is why the client constant moves in this same PR. Roll the universal
validator fleet before, or with, the chain — getting this backwards is a worse outage than the bug.

Tests

app/ante/gasless_gas_limit_test.go (unit, 9 tests):
above/at/below the cap; MaxInt64; a fee-paying tx with MaxInt64 is not capped (and the params
are not even read); the exact authz.MsgExec-wrapping-a-vote shape at 500M (rejected) and 100M
(accepted); a governance-lowered cap binding below the default and a raised one admitting 400M; a
zero parameter and a params read failure both falling back to the default; simulation capped too.

test/integration/ante/gasless_gas_limit_test.go (chain-level, real signed txs through
baseapp → ante → EndBlock, with max_gas set to -1):

  • TestGaslessCumulativeGasWantedCannotFailFinalizeBlock — the finding itself: two gasless txs each
    declaring MaxInt64 in one block. FinalizeBlock must return no error, both txs must be
    rejected, and the block's gas wanted must stay near zero.
  • TestGaslessTxAboveCapRejected / TestGaslessTxAtCapAccepted — the boundary, asserted through
    GetBlockGasWanted (a 100M declaration shows up as >= 50M via MinGasMultiplier 0.5) rather than
    through the log alone.
  • TestGaslessTxAtUniversalValidatorGasAccepted — the fleet's new 100,000,000 still votes.
  • TestGaslessCapIsAGovernanceParameter — 40M is admitted under the default, then a real
    MsgUpdateParams from the gov authority lowers the cap to 30M and the same 40M tx is rejected
    on the next block while 30M still passes; a zero cap is refused and leaves the stored cap intact.

x/uexecutor/types/params_test.go — the shipped default, zero rejected, governance-chosen caps accepted.

Mutation check

With the enforcement reverted (if false && gas > maxGas, if false && p.MaxGaslessTxGas == 0) and
the tests kept, the chain-level test reproduces the finding exactly:

    gasless_gas_limit_test.go:174:
        Error:      Received unexpected error:
                    integer overflow by integer type conversion. Gas wanted > MaxInt64. Gas wanted: 18446744073709551614
        Messages:   FinalizeBlock must survive the cumulative gas wanted

and every cap assertion fails with it — "50000000" is not less than "1000000",
"20000000" is not less than "1000000", expected: 0x5f5e100 / actual: 0x0, plus Should be false
on each unit-level "must not reach the next decorator". Restored, all three packages are green.

Existing tests updated

test why
x/uexecutor/types/genesis_test.go &GenesisState{} was asserted valid; an empty Params now leaves the cap at 0 and is rejected at genesis. Case flipped to valid: false.
test/integration/uexecutor/evm_hooks_and_outbound_test.go built Params{SomeValue: ...} from scratch, dropping the cap. Now mutates a copy of the existing params.
test/utils/contracts_setup.go InitGenesis(&GenesisState{}) would now fail validation and silently skip the factory deployment. Uses DefaultGenesis().
universalClient/pushsigner/vote_test.go pinned 500,000,000. Now pins 100,000,000 and asserts defaultGasLimit <= uexecutortypes.DefaultMaxGaslessTxGas, so client and chain cannot drift apart again.

Nothing else in-tree declares a gasless gas limit above 100,000,000: the shell scripts use
--gas=auto, 50000, 300000 and 600000.

Full suite

go test -mod=readonly -p 1 -count=1 -tags="ledger test_ledger_mock test" ./x/... ./test/integration/...
  15 ok, 0 FAIL (14 packages with no test files)

go test -mod=readonly -p 1 -count=1 -tags="ledger test_ledger_mock test" ./app/... ./universalClient/...
  29 ok, 0 FAIL

Companion PR

This is the ante half. The fee-market half — clamping the cumulative gas wanted in EndBlock
instead of returning an error, and making AddTransientGasWanted saturate rather than wrap — is
pushchain/push-chain-evm#50. The cap closes the only currently reachable route; the clamp removes
the halt for any future one, independently of max_gas and MinGasPrice staying sensibly set.
Sequence the two together.

Consolidation

One cap retires three findings' recommendations: 18144 rec 6, 18816 rec 3, 18182 rec 6.

…(F-2026-18144)

Fee-paying txs are self-limiting: the ante handler requires
ceil(minGasPrice * gasLimit), so an absurd gas limit costs absurd money.
Gasless txs pay nothing, so nothing bounded the gas they declared, while that
declared gas was still added to the fee market's cumulative gas wanted for the
block. Under max_gas: -1 the per-tx block-limit check is inert, so two gasless
txs each declaring MaxInt64 sum past what EndBlock can convert to int64 - an
error that surfaces through FinalizeBlock after the block is decided.

New uexecutor param max_gasless_tx_gas (default 100,000,000), enforced by
GaslessGasLimitDecorator on the existing txpolicy.IsGaslessTx predicate,
before NewGasWantedDecorator accumulates the declaration. A parameter and not
a constant because "too low" stops the universal validators voting and must be
fixable by proposal in minutes. Zero is rejected at genesis and on update, and
an unreadable or unset parameter falls back to the default rather than to
"no cap".

universalClient/pushsigner declares 100,000,000 instead of 500,000,000. Live
donut data: gas_wanted median/max 500,000,000 against a gas_used max of
5,011,877 - the largest real consumer used 1.0024% of what it declared.
@0xNilesh
0xNilesh merged commit a5aead7 into audit-fixes Aug 26, 2026
2 checks passed
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