fix: F-2026-18197 | [Dual Defense] Nested Message Dispatch Bypasses EVM Ante for MsgEthereumTx - #41
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
F-2026-18197 — harden the sink (EVM side)
Keeper.EthereumTxwent straight tok.ApplyTransaction(ctx, msg.AsTransaction())with no sendervalidation 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.Msgand re-dispatches it through the message router —x/authz MsgExec,x/group MsgSubmitProposal/MsgExec,x/govproposals, a CosmWasm stargate/Anymessage, ICA —hands the nested
MsgEthereumTxto this handler with the ante already behind it. An attacker copiesa victim-signed tx off the mempool, nests it, and it executes as the victim; because
state_transition.goforce-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)beforeApplyTransaction, wrapped aserrortypes.ErrorInvalidSigner, using the same signer construction asante/evm/05_signature_verification.go.VerifySender(x/vm/types/msg.go) compares the declaredmsg.Fromagainst the ECDSA-recovered signer, so the executor no longer trusts an unverifiedFromwhatever 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 isFrom— so the attacker must setFromto that account while recovery yields the victim (mismatch → rejected). SetFromto thevictim instead and the dispatcher's own permission check fails (no grant from the victim). Either
way it dies.
Also rejects an
MsgEthereumTxwhoserawfield is empty.EthereumTx.UnmarshalsetsRaw.Transaction = nilfor empty bytes, andValidateBasiclives in the very ante path a nestedmessage 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()returnsmsg.Raw.Transaction— the same*ethtypes.Transactionobject the ante already verified — and go-ethereum caches the recoveredsender on the transaction per signer, so this is a cache hit, not a second ECDSA recovery.
Upstream
cosmos/evmmainstill has noVerifySenderinKeeper.EthereumTx(verified). Its executorremains 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, thevery 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.gocallsIncrementNoncebefore the msg server runs, so atthis point the invariant is
tx.Nonce() + 1 == acct.Sequence, not equality. Asserting that herewould couple the executor to ante bookkeeping and break every legitimate direct keeper call
(
x/vmhooks, precompile integration tests). Worth stating plainly:VerifySenderalone does notstop replay of a genuine victim-signed tx — replay protection stays in the ante, where the
sequence is actually advanced. With
x/groupand the wasmstargatecapability removed on thechain side, and
x/authzstill covered byAuthzLimiterDecorator, there is no ante-skipping pathleft to replay through; a future dispatch module would need its own review.
Tests
TestEthereumTxSenderVerification(tests/integration/x/vm/test_msg_server.go), 4 cases:ran
VerifySender, so this check is a redundant no-op there).Fromspoofed to a different account: the actual attack. Rejected withErrorInvalidSigner,nilresponse, noethereum_txevent emitted.Fromspoofed to a module account. A module account is derived from a name, never froma 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.Fromcleared.&types.MsgEthereumTx{}) returnsErrInvalidRequestinstead ofpanicking.
Results: the new suite passes, and the full
TestKeeperTestSuite(includingTestEthereumTxandTestEvmHooks, which callEthereumTxdirectly), plusTestVmAnteTestSuite,TestNestedEVMExtensionCallSuite,TestGenesisTestSuiteandTestIterateContractsare green.TestKeeperTestSuite/TestRefundGas/Case_invalid_GasPrice_in_messagefails — confirmedpre-existing, it fails identically on
audit-fixeswith this change stashed, and it exercisesRefundGasdirectly, nowhere near the msg server. The precompile suites' directEthereumTxcallsuse
SignMsgEthereumTx, soFromis 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 anMsgEthereumTx. They reach the EVM viaCallEVM/DerivedEVMCall, which go straight toApplyMessageWithConfig. Neither the chain northe universal client ever constructs an
MsgEthereumTx.This was checked against real code, not asserted: the chain PR's
TestGaslessExecutePayloadWithModuleSenderwas run withgithub.com/cosmos/evmreplaced by thisbranch, and the whole
test/integration/uexecutorpackage passes — a gasless module-senderMsgExecutePayloadstill executes end to end.Companion PR (chain side, removes the two dispatchers): pushchain/push-chain-node#317