Skip to content

fix: F-2026-18816 | [Dual Defense] Empty authz.MsgExec Vacuously Gasless Can Stuff Declared Block Gas - #332

Merged
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18816
Aug 26, 2026
Merged

fix: F-2026-18816 | [Dual Defense] Empty authz.MsgExec Vacuously Gasless Can Stuff Declared Block Gas#332
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18816

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

Summary

IsGaslessTx treats an authz.MsgExec as gasless when every inner message is allowlisted. With
Msgs: [] that loop runs zero times, falls through, and the function returns true by vacuous truth —
so a zero-fee tx skips DeductFeeDecorator and MinGasPriceDecorator.

The outer message list already had exactly this guard six lines earlier (if len(msgs) == 0 { return false });
only the nested list was missed. This PR adds the matching guard to the nested list.

case *authz.MsgExec:
    // An empty nest would pass the loop below vacuously and make the whole
    // tx gasless, bypassing the fee and min-gas-price decorators (F-2026-18816).
    if len(m.Msgs) == 0 {
        return false
    }
    for _, innerMsg := range m.Msgs {

That is the entire production change (5 lines in app/txpolicy/gasless.go).

Why nothing upstream catches it

  • authz.MsgExec has no ValidateBasic in SDK v0.53.7 (only UnpackInterfaces / GetMessages),
    so ante.NewValidateBasicDecorator() cannot reject it.
  • The empty check lives only in x/authz/keeper/msg_server.go — that is DeliverTx, i.e. after the
    fee decorators have already been skipped.
  • AuthzLimiterDecorator filters only MsgEthereumTx and MsgCreateVestingAccount inside a MsgExec.

Impact assessment — Hacken's headline does not apply to Push

Hacken's stated impact is block-gas censorship via declared gas. That vector needs a finite
max_gas
to monopolise. It does not apply here:

  • Live donut consensus params run max_gas: "-1".
  • The in-tree update_max_block_gas.json governance proposal also sets max_gas: "-1" — unlimited is
    deliberate, not drift.

With no block gas budget, declared gas cannot monopolise block capacity, so the censorship scenario is moot.

What is real, and what this PR fixes:

  1. Fee / min-gas-price bypass — free spam, bounded only by max_bytes (22020096, ~21 MiB) rather
    than by fees. This is the substantive issue and it is closed by the guard.
  2. Free account creationapp/ante/account_init_decorator.go:34 gates on this same
    IsGaslessTx, so a vacuously-gasless tx also gets a free on-chain account created for its signer
    (permanent state bloat, and notably no finite max_gas required).

Honest caveat on (2): this account-init amplifier is largely shared with the gasless account-init
behaviour already tested and accepted under F-2026-18200. An empty MsgExec makes it cheaper to
trigger, not newly possible
— it is not an independent new capability.

Tests

Added to the existing app/txpolicy/gasless_test.go, in its existing style:

  • TestIsGaslessTxAuthzExecNesting (table):
    • empty nest (nil) → not gasless (the regression case)
    • empty nest (zero-length but non-nil) → not gasless
    • all-allowlisted nest → gasless (unchanged behaviour)
    • mixed allowlisted / non-allowlisted nest → not gasless (unchanged behaviour)
    • MsgExec nested inside a MsgExecnot gasless — confirmed: /cosmos.authz.v1beta1.MsgExec
      is not itself in the allowlist, so the inner Any's TypeURL check rejects it and there is no
      recursion into a vacuous pass
  • TestIsGaslessTxEmptyExecAlongsideAllowedMsg — an empty MsgExec sitting next to an allowlisted
    sibling message must still disqualify the whole tx.

Mutation-verified

With the len(m.Msgs) == 0 guard removed, exactly the three empty-nest assertions fail and every
unchanged-behaviour case still passes:

--- FAIL: TestIsGaslessTxAuthzExecNesting/empty_nest_is_not_gasless
        expected: false / actual: true
--- FAIL: TestIsGaslessTxAuthzExecNesting/empty_non-nil_nest_is_not_gasless
        expected: false / actual: true
--- PASS: TestIsGaslessTxAuthzExecNesting/all-allowlisted_nest_stays_gasless
--- PASS: TestIsGaslessTxAuthzExecNesting/mixed_nest_is_not_gasless
--- PASS: TestIsGaslessTxAuthzExecNesting/nested_MsgExec_is_not_gasless
--- FAIL: TestIsGaslessTxEmptyExecAlongsideAllowedMsg

Guard restored, all pass.

Deliberately out of scope

# Hacken rec Call
2 Reject empty MsgExec in the ante chain Skip — rec 1 removes the bypass, the msg server already rejects it, and a blanket authz guard belongs upstream in the SDK
3 Cap declared gas for gasless txs Separate ticket — overlaps F-2026-18182; only matters if max_gas ever goes finite
4 Custom PrepareProposal Decline — large change, moot at max_gas: -1

GaslessMsgTypes membership is untouched.

An empty inner message list passed the allowlist loop vacuously, making the tx gasless and skipping the fee and min-gas-price decorators.
@0xNilesh
0xNilesh merged commit b851fde into audit-fixes Aug 26, 2026
7 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