Skip to content

fix: F-2026-18821 | [Dual Defense] Public InboundKeys Query Runs Unbounded Quadratic Solana Base58 Decode - #329

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

fix: F-2026-18821 | [Dual Defense] Public InboundKeys Query Runs Unbounded Quadratic Solana Base58 Decode#329
0xNilesh merged 1 commit into
audit-fixesfrom
F-2026-18821

Conversation

@0xNilesh

Copy link
Copy Markdown
Member

Finding

Querier.InboundKeys is an unauthenticated gRPC/REST helper that derives the canonical UTX id and inbound ballot id for an inbound. Its entire input validation was a nil check. It reads no state, so it consumes no gas — Cosmos meters store operations, not CPU.

It then canonicalizes the tx hash three times: inbound.Canonicalize(), then GetInboundBallotKey and GetInboundUniversalTxKey, both of which self-canonicalize by design.

For a solana: source chain each of those reached canonicalizeSolanaTxHash, which called base58.Decode on any base58-alphabet string of any length and — when the result wasn't exactly 64 bytes — discarded it and returned the input unchanged. mr-tron/base58 v1.2.0's decoder is quadratic: for each of n characters it walks ceil(n/4) limbs.

So a caller sends a 100,000-character string, the node does the full Θ(n²) decode three times, throws all three results away, and returns the input.

Measured locally (machine under load, so order-of-magnitude):

n=1000   483µs     n=4000  10.3ms    n=16000  750ms
n=2000  1.929ms    n=8000  59.8ms    n=32000  2.05s

single decode n=1e5: 4.5s  |  3 decodes (the InboundKeys shape) n=1e5: 17.8s

Reachable on donut today. uexecutor.v1.Query routes through the public CometBFT RPC — verified with a normal-sized benign payload (/abci_query?path="/uexecutor.v1.Query/Params"code: 0). InboundKeys is an RPC on that same service, so it's reachable with no auth and no fee.

The consensus path is in better shape: msgServer.VoteInbound checks IsBondedUniversalValidator and IsTombstonedUniversalValidator before Keeper.VoteInbound, so only a bonded, non-tombstoned UV can drive the same decode inside DeliverTx. Fixed anyway, since it's the same shared helper.

Why this is safe to change on a consensus-path helper

A base58 string can decode to exactly 64 bytes only if its length is in [64, 88] — 88 is ceil(512 / log2(58)), 64 is the all-zero case where each leading zero byte encodes as one '1'. I verified the band by exhaustive probe rather than deriving it.

Anything outside that band already fell through to return canon unchanged. Gating the decode on the band is therefore output-equivalent for every possible input: same canonical string, same ballot key, same UTX key. No state-machine break, no upgrade handler.

The equivalence test asserts exactly this against a reference implementation of the old behaviour.

Changes

utils/canonical.gocanonicalizeSolanaTxHash attempts base58.Decode only when 64 <= len(canon) <= 88. This is the single choke point, so it fixes the querier, the vote path and admin_revert.go together.

x/uexecutor/keeper/query_keys.gomaxQueryTxHashLen = 128; InboundKeys rejects an oversized tx_hash with InvalidArgument. Defence-in-depth on the unauthenticated surface. Deliberately not applied to raw_payload/verification_data (legitimately long), and not pushed down into utils.Canonicalize* — the vote path must stay lenient, since a malformed inbound still has to produce a UTX, and changing shared canonicalization would move ballot keys.

Tests

utils/canonical_test.go:

  • output-equivalence sweep against a reference full-decode implementation, lengths 1–300 × 4 character variants, including the 63/64/65/87/88/89 edges
  • a real 88-char base58 signature still folds to 0x-hex
  • n=1e5 passes through unchanged in under 1s

x/uexecutor/keeper/query_keys_test.go: oversized tx_hash rejected fast; real Solana signature accepted; 128 chars accepted, 129 rejected.

Mutation-verified — each half reverted, re-run, restored:

mutation result
remove the length band OversizedInputDoesNotDecode fails (3.56s vs the 1s bound); equivalence and real-signature tests still pass, confirming the band changes no output
remove the InboundKeys cap RejectsOversizedTxHash and TxHashAtCapIsAccepted fail

Hacken's remediations

  1. Cap base58 input before decode — done, in the shared helper rather than only at the query boundary.
  2. Decode once per request, reuse the canonical string — declined. GetInboundBallotKey/GetInboundUniversalTxKey self-canonicalize deliberately ("any caller gets one ballot per logical event"), and that guard is worth keeping. It's also moot: a well-formed hash becomes 0x… on the first pass and short-circuits, so the repeat cost only ever hit malformed input, which rec 1 removes.
  3. Skip decode outside the plausible band — done, as the two-sided [64, 88] bound rather than a one-sided ≤ 128; the band is exact.
  4. gRPC/REST body limits and timeouts — worth doing as ops follow-up, but not the fix: CometBFT's default max_body_bytes is 1 MB, which still admits n ≈ 1e6, far worse than anything measured here. Ops limits shrink the blast radius; only the code cap closes it.
  5. Regression test — done, asserting the value plus a bound loose enough not to flake on CI and tight enough to fail on any return to Θ(n²).

Follow-up (not in this PR)

solanaAddressBytes / AddressToBytes32 has the identical unbounded-decode shape, expecting 32 bytes → band [32, 44]. It doesn't exist on audit-fixes — it's on the PC20 branch — so it should get the same one-liner when PC20 lands.

…h band

Only 64..88 base58 chars can decode to 64 bytes, so gating the decode on that
band is output-equivalent. Also cap tx_hash on the unauthenticated InboundKeys
query.
@0xNilesh
0xNilesh merged commit b3a2058 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