Skip to content

fix: F-2026-18200 | [Dual Defense] Variable-Length Signer Addresses Enable Universal Executor Module Impersonation - #316

Open
0xNilesh wants to merge 2 commits into
audit-fixesfrom
F-2026-18200
Open

fix: F-2026-18200 | [Dual Defense] Variable-Length Signer Addresses Enable Universal Executor Module Impersonation#316
0xNilesh wants to merge 2 commits into
audit-fixesfrom
F-2026-18200

Conversation

@0xNilesh

@0xNilesh 0xNilesh commented Aug 20, 2026

Copy link
Copy Markdown
Member

Problem

A gasless transaction can declare a crafted, over-long Bech32 signer while being signed by an entirely unrelated key, and end up impersonating the uexecutor module inside the UEA. Three links:

  1. No pubkey ↔ signer binding. app/ante/account_init_decorator.go calls authsigning.VerifySignature(...), which only proves "this key signed this tx" — it never checks pubKey.Address() == signer. The decorator then early-returns, so the SDK's SetPubKeyDecorator, which does enforce exactly that (x/auth/ante/sigverify.go:94-96, present since v0.46), is never reached.
  2. No length enforcement. utils.ConvertAnyAddressToBytes returns whatever sdk.AccAddressFromBech32 accepts — up to 255 bytes.
  3. Silent truncation. utils.GetAddressPair uses common.BytesToAddress(bz), which keeps the rightmost 20 bytes. So a 21-byte signer 0x01 ‖ sha256("uexecutor")[:20] collapses to the module EVM address 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7 — which the UEA contract trusts unconditionally.

Result: sign with any key, declare any signer, become the module, and drive any UEA. Truncation works for any signer longer than 20 bytes, not just the 21-byte case in the report.

Fix

1. Bind the signer to the signing key in the ante (app/ante/account_init_decorator.go).
verifySignatureForNewAccount now rejects the tx unless pubKey.Address() == signers[i], with the SDK's guards mirrored exactly (!simulate && ctx.IsSigverifyTx()), so simulation and gas estimation are unaffected. Because a pubkey address is always 20 bytes, this alone kills every aliased signer of any length.

Implementation note: the plan's preferred route was to drop the early return and let the tx fall through to SetPubKeyDecorator/SigVerificationDecorator. That is not viable: this path exists precisely because the account does not exist yet, so it signs over accountNumber = 0 / sequence = 0, while SigVerificationDecorator verifies against acc.GetAccountNumber() — a number the chain only assigns at creation time and which a client therefore cannot know in advance. Falling through would break every first-time gasless tx. The in-place check was taken instead.

2. Also resolves F-2026-18186 (Medium — the same early return skipped ValidateSigCount, the quadratic-multisig vector). The new-account path now enforces params.TxSigLimit before doing any verification work, so an oversized multisig key is rejected outright rather than verified sub-signature by sub-signature.

No gas is consumed on this path, deliberately. The gasless path skips fee deduction entirely (app/ante/fee.go:60), so charging gas in a fee-exempt tx has no economic effect — it costs an attacker nothing and buys no DoS protection, while introducing a behaviour change on the consensus-critical validator vote path (the gasless message list is almost entirely MsgVote*). The hard count cap is what actually bounds the work; SigGasConsumer would only have been the economic disincentive, which is meaningless without a fee. Both findings stay closed with zero behaviour change.

3. Enforce exactly 20 bytes on Cosmos→EVM conversion (utils/address.go).
GetAddressPair now errors instead of truncating. Non-breaking: it already returned error and has only two callers (x/uexecutor/keeper/msg_server.go:44,59), both of which already handle it.

4. MustConvertCosmosToHex hardened — not named in the report. It used common.Address(bz), a slice→array conversion that panics below 20 bytes and keeps the leftmost 20 above — the opposite end from BytesToAddress in the same file. It now returns "" for anything that is not exactly 20 bytes.

5. Outer guard at CheckTx. MsgExecutePayload.ValidateBasic and MsgMigrateUEA.ValidateBasic reject a signer that does not decode to exactly 20 bytes, so the tx dies before the ante chain runs.

Tests

  • app/ante/account_init_signer_binding_test.go — builds real SIGN_MODE_DIRECT transactions signed by an unrelated key:
    • aliased signers of 21, 22 and 32 bytes, each ending in the module address, against both MsgExecutePayload and MsgMigrateUEA → all rejected with ErrInvalidPubKey, next never called, no account persisted;
    • a well-formed 20-byte signer that is not the key's address → rejected;
    • positive control: matching 20-byte signer, both messages → succeeds, account created at sequence 1;
    • simulation path: simulate=true, matching and mismatched → unaffected;
    • multisig above TxSigLimitErrTooManySignatures (F-2026-18186).
      Verified as genuine regressions: with the two new checks disabled, all six aliased-signer cases return no error and create the account.
  • utils/address_test.goGetAddressPair rejects 19/21/22/32 bytes and accepts 20 (bech32 and 0x); the module-alias case is asserted to truncate under BytesToAddress and to be rejected by GetAddressPair; MustConvertCosmosToHex neither panics nor truncates.
  • x/uexecutor/types/msg_signer_length_test.goValidateBasic on both gasless messages rejects 21/22/32-byte signers and accepts 20.
  • test/integration/uexecutor — one fixture used a literal that decoded to 42 bytes (the ASCII of a hex address, bech32-encoded by mistake), previously truncated down to a 20-byte address by GetAddressPair. Replaced with a testSigner constant holding exactly those 20 bytes, so behaviour is unchanged.

Green: ./app/... (-tags=test), ./x/..., ./utils/..., ./test/integration/uexecutor/....

Hacken remediation 3 — declined

"Remove or tightly redesign the contract's unconditional module-caller signature bypass."

  • The bypass is load-bearing: the legitimate inbound path calls ExecutePayloadV2 with from = uexecutor module precisely so the UEA skips EIP-712 — authorisation there comes from validator consensus, not an owner signature. Removing it breaks cross-chain inbound execution.
  • It would require shipping a new UEA implementation and migrating every existing UEA proxy (setUEAProxyImplementation) — high blast radius, real migration risk.
  • With the fixes above, no attacker can reach the module address, so the bypass is unreachable. Marginal security gain ≈ zero.

…-20-byte addresses

Ante now enforces pubKey.Address() == signer for new gasless accounts, and
GetAddressPair / MustConvertCosmosToHex reject anything that is not exactly
20 bytes instead of truncating onto a module address.
Gasless txs skip fee deduction entirely, so charging gas has no economic
effect. The TxSigLimit cap alone bounds the multisig work; reverts the
NewAccountInitDecorator signature change and the SigGasConsumer wiring.
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