fix: F-2026-18803 | [Dual Defense] Gasless MsgVoteChainMeta Can Inflate Unregistered ChainMetas Keys - #333
Merged
Merged
Conversation
Gate Keeper.VoteChainMeta on uregistry before any state read/write, and cap observed_chain_id length + CAIP-2 shape in ValidateBasic.
Assert no ChainMetas row is written, at keeper and integration level.
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.
Issue — nothing in the vote's path ever asks whether the chain exists
ValidateBasicobserved_chain_id != "", price > 0, height > 0 — shape only, no length or CAIP-2 capmsgServer.VoteChainMetaIsBondedUniversalValidator+IsTombstoned— who votes, never what they vote onKeeper.VoteChainMetaGetChainMetamiss → build entry →SetChainMeta— the miss creates the rowchain_meta.gocold-start path (!bootstrapped && len(fresh) < chainMetaMinVotesForFirstWrite, = 3)calls
SetChainMetaand returns. The quorum of 3 gates the EVM oracle write, never the storewrite — votes have to accumulate somewhere to ever reach three.
Both axes are attacker-controlled.
ChainMetasiscollections.Map[string, types.ChainMeta]with
collections.StringKey, so the map key is the rawobserved_chain_id, stored verbatim as theIAVL key and uncapped. Unbounded row count and unbounded key size (a 100 KB id writes a 100 KB
key; donut
max_bytesis 21 MB). The id is stored twice per row — key, plus field 1 of the value.The real cost is the walk, not the disk.
PruneValidatorVotes(gas_price.go:21) does anunpaginated
ChainMetas.Walkdeserialising every key and value, and it is called fromAfterValidatorRemoved— EndBlock-class, unmetered. The attacker pays metered gas per row onceat write time; every node pays O(N) during consensus later.
Mitigating factor — stated honestly
gas_price.go:41-46: when the pruned validator is the row's last signer the entry isRemoved,not edited. Every fake row has exactly one signer, so ejecting the malicious UV sweeps the entire
set. The bloat persists only while the attacker stays bonded, and the consensus-time walk is the
only thing that actually bites. Combined with the bonded-UV precondition — an already-trusted role
with considerably worse options available elsewhere — this is genuinely Impact 1 / Likelihood 1.
It is fixed because the gate is one line and the invariant ("chain meta only exists for registered
chains") is worth having explicitly, not because the finding is severe.
Fix — two layers, because
ValidateBasicis stateless1. Registry gate in the keeper — at the very top of
Keeper.VoteChainMeta, aboveGetChainMeta:Position is load-bearing: the cold-start branch reaches
SetChainMetaon the very first vote, so thegate has to precede any state read or write. Mirrors
VoteInbound(
msg_vote_inbound.go:32, "Check inbound enabled before any state changes"). The underlyingcollections.ErrNotFoundis wrapped, not swallowed.Gate is on registered, deliberately not
IsChainInboundEnabled: chain meta also feedsgas-price quoting for outbounds, so an inbound-enabled check would starve outbound-only chains.
2. Length + CAIP-2 shape cap in
ValidateBasic— stateless, so it cannot query the registry;it gets the cheap CheckTx-time bound instead.
MaxObservedChainIdLen = 128: CAIP-2 permits at most8 + 1 + 32 = 41 characters and our longest real id is
solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1(41), so 128 is generous headroom while still bounding thekey. Shape check reuses the existing
types.ParseCAIP2helper rather than adding a parser, and isthe same bar
Inbound.ValidateBasicalready applies tosource_chain.Layer 1 bounds the row count; layer 2 bounds each row's size. Neither alone suffices.
Declined / skipped Hacken recommendations
Setuntil bootstrap quorumChainMetascardinalityTests
New
x/uexecutor/keeper/chain_meta_test.go:ChainMetasrow, asserted byHas+ a fullWalkof the mapLastAppliedChainHeightstill 0 below quorumNew
x/uexecutor/types/msg_vote_chain_meta_test.go— 12ValidateBasictable cases: both real idformats accepted, boundary at exactly 128 accepted / 129 rejected, 100 KB id rejected, non-CAIP-2 /
empty-namespace / empty-reference rejected, and the four pre-existing checks still enforced.
New subtest in
test/integration/uexecutor/vote_chain_meta_test.godriving the full authz →msgServer → keeper path, asserting the store is unchanged and that the registered chain still votes
fine from the same validator immediately afterwards.
In both the keeper and integration tests the store assertion is placed before the error
assertion, deliberately: the finding is the row being written, not a missing error, so that is the
assertion the mutation check has to break.
Mutation-verified
With the registry gate removed, the store assertions fail — including a dump of the 207-character
IAVL key the bug actually mints:
Gate restored; all pass again.
Verification
Full CI invocation on this branch — green, 0 failures:
Also green on the client-side packages that construct this message
(
universalClient/pushsigner,universalClient/chains/...,utils).No existing test needed changing: both chain-meta integration fixtures
(
setupVoteChainMetaTest,setupValidatorPruningTest) already callAddChainConfigforeip155:11155111, so every pre-existing vote is for a registered chain.No CHANGELOG entry — the node repo generates it per release.