Skip to content

fix: F-2026-18197 | [Dual Defense] Nested Message Dispatch Bypasses EVM Ante for MsgEthereumTx - #41

Merged
0xNilesh merged 3 commits into
audit-fixesfrom
F-2026-18197
Aug 24, 2026
Merged

fix: F-2026-18197 | [Dual Defense] Nested Message Dispatch Bypasses EVM Ante for MsgEthereumTx#41
0xNilesh merged 3 commits into
audit-fixesfrom
F-2026-18197

Conversation

@0xNilesh

@0xNilesh 0xNilesh commented Aug 20, 2026

Copy link
Copy Markdown
Member

F-2026-18197 — harden the sink (EVM side)

Keeper.EthereumTx went straight to k.ApplyTransaction(ctx, msg.AsTransaction()) with no sender
validation at all
. Every Ethereum check — signature, nonce, gas — lives in the EVM ante handler,
and the msg server simply assumes it ran.

It has not always run. The ante chain only covers a tx's top-level messages, so any module that
unpacks an embedded sdk.Msg and re-dispatches it through the message router — x/authz MsgExec,
x/group MsgSubmitProposal/MsgExec, x/gov proposals, a CosmWasm stargate/Any message, ICA —
hands the nested MsgEthereumTx to this handler with the ante already behind it. An attacker copies
a victim-signed tx off the mempool, nests it, and it executes as the victim; because
state_transition.go force-sets the sender's nonce (// - reset sender's nonce to msg.Nonce() before calling evm) instead of checking it, the same tx replays indefinitely.

Change

Require msg.VerifySender(signer) before ApplyTransaction, wrapped as
errortypes.ErrorInvalidSigner, using the same signer construction as
ante/evm/05_signature_verification.go. VerifySender (x/vm/types/msg.go) compares the declared
msg.From against the ECDSA-recovered signer, so the executor no longer trusts an unverified
From whatever route the message took.

Why this kills both nested vectors: a dispatcher forces the nested message's signer to be the
policy/grantee/module account, and MsgEthereumTx's signer is From — so the attacker must set
From to that account while recovery yields the victim (mismatch → rejected). Set From to the
victim instead and the dispatcher's own permission check fails (no grant from the victim). Either
way it dies.

Also rejects an MsgEthereumTx whose raw field is empty. EthereumTx.Unmarshal sets
Raw.Transaction = nil for empty bytes, and ValidateBasic lives in the very ante path a nested
message skips — so the msg server cannot assume it ran. Previously that dereferenced nil (recovered
by baseapp, but as a panic rather than an error); now it returns ErrInvalidRequest.

Cost on the normal path is ~zero. msg.AsTransaction() returns msg.Raw.Transaction — the same
*ethtypes.Transaction object the ante already verified — and go-ethereum caches the recovered
sender on the transaction per signer, so this is a cache hit, not a second ECDSA recovery.

Upstream

cosmos/evm main still has no VerifySender in Keeper.EthereumTx (verified). Its executor
remains dependent on the ante having run, so this puts the fork ahead of upstream and is worth
responsibly disclosing.

The bug class itself is upstream: Evmos GHSA-v6rw-hhgg-wc4x is this exact issue
("MsgEthereumTx nested under other messages … do not meet the checks performed under
newEthAnteHandler"), patched ≥ v12.0.0 — and the fix they shipped was AuthzLimiterDecorator, the
very denylist we already run. It held until more dispatch modules were enabled underneath it. That
is why the durable fix belongs here at the sink rather than in another denylist.

Nonce check — considered, deliberately not added

The plan listed "enforce nonce vs account sequence" as optional defence in depth. It does not fit
this seam: ante/evm/mono_decorator.go calls IncrementNonce before the msg server runs, so at
this point the invariant is tx.Nonce() + 1 == acct.Sequence, not equality. Asserting that here
would couple the executor to ante bookkeeping and break every legitimate direct keeper call
(x/vm hooks, precompile integration tests). Worth stating plainly: VerifySender alone does not
stop replay
of a genuine victim-signed tx — replay protection stays in the ante, where the
sequence is actually advanced. With x/group and the wasm stargate capability removed on the
chain side, and x/authz still covered by AuthzLimiterDecorator, there is no ante-skipping path
left to replay through; a future dispatch module would need its own review.

Tests

TestEthereumTxSenderVerification (tests/integration/x/vm/test_msg_server.go), 4 cases:

  • pass — untouched victim-signed tx still succeeds (the normal JSON-RPC path; the ante already
    ran VerifySender, so this check is a redundant no-op there).
  • failFrom spoofed to a different account: the actual attack. Rejected with
    ErrorInvalidSigner, nil response, no ethereum_tx event emitted.
  • failFrom spoofed to a module account. A module account is derived from a name, never from
    a key pair, so no signature can recover to it — this is what makes it a design invariant that
    module-driven EVM calls use ApplyMessage* directly and never this msg server.
  • failFrom cleared.
  • fail — empty raw transaction (&types.MsgEthereumTx{}) returns ErrInvalidRequest instead of
    panicking.

Results: the new suite passes, and the full TestKeeperTestSuite (including TestEthereumTx and
TestEvmHooks, which call EthereumTx directly), plus TestVmAnteTestSuite,
TestNestedEVMExtensionCallSuite, TestGenesisTestSuite and TestIterateContracts are green.
TestKeeperTestSuite/TestRefundGas/Case_invalid_GasPrice_in_message fails — confirmed
pre-existing
, it fails identically on audit-fixes with this change stashed, and it exercises
RefundGas directly, nowhere near the msg server. The precompile suites' direct EthereumTx calls
use SignMsgEthereumTx, so From is the real signer and they are unaffected.

Gasless / module-sender compatibility — verified, not assumed

Push's gasless flows never reach this handler: MsgMigrateUEA, MsgExecutePayload,
MsgVoteInbound, MsgVoteOutbound, MsgVoteTssKeyProcess, MsgVoteFundMigration,
MsgVoteChainMeta (app/txpolicy/gasless.go) — none is an MsgEthereumTx. They reach the EVM via
CallEVM / DerivedEVMCall, which go straight to ApplyMessageWithConfig. Neither the chain nor
the universal client ever constructs an MsgEthereumTx.

This was checked against real code, not asserted: the chain PR's
TestGaslessExecutePayloadWithModuleSender was run with github.com/cosmos/evm replaced by this
branch
, and the whole test/integration/uexecutor package passes — a gasless module-sender
MsgExecutePayload still executes end to end.


Companion PR (chain side, removes the two dispatchers): pushchain/push-chain-node#317

The EVM ante only runs over a tx's top-level messages, so an MsgEthereumTx
nested inside a dispatching module reaches the executor with its signature
unchecked (F-2026-18197). Require VerifySender in the msg server so From is
always the recovered signer, whatever route the message took.
ValidateBasic lives in the same ante path a nested message skips, so the msg
server must not dereference a nil transaction.
@0xNilesh
0xNilesh merged commit cf39387 into audit-fixes Aug 24, 2026
14 of 21 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