Skip to content

fix(10-gno): bind validator addresses to pubkeys and bound PartSetHeader.Total during conversion - #367

Open
clockworkgr wants to merge 7 commits into
atomone-hub:mainfrom
clockworkgr:fix/gno-validator-address-binding
Open

fix(10-gno): bind validator addresses to pubkeys and bound PartSetHeader.Total during conversion#367
clockworkgr wants to merge 7 commits into
atomone-hub:mainfrom
clockworkgr:fix/gno-validator-address-binding

Conversation

@clockworkgr

@clockworkgr clockworkgr commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two hardening fixes to the 10-gno light client's protobuf-to-gno conversion layer in modules/10-gno/helpers.go, both reported through the bug bounty program and both classified as defense-in-depth rather than exploitable vulnerabilities. The first addresses report TMNSC-292, the second addresses report TMNSC-282.

1. Validator addresses were not bound to their public keys (TMNSC-292)

ConvertToGnoValidatorSet accepted any syntactically valid, unique address string for a validator without checking that it is derived from the validator's public key. Native gno enforces this binding in its validator set constructor (validator address doesn't match pubkey), so the light client diverged from gno's own validation.

Because Validator.Bytes() (and therefore ValidatorSet.Hash()) excludes the address, and commit sign bytes exclude the validator address and index, a relayer could attach distinct addresses to a single public key and have one signature counted in several slots of a relayer-supplied validator set.

The reported forgery only succeeds when the attacker already holds more than the configured trust level of the trusted validator set, under which a correct light client is forgeable by design (the new set is relayer-supplied and only bound by the header's validators hash). Below the trust level the missing check has no effect.

Changes:

  • Require address == pubkey.Address() for every validator.
  • Key the duplicate-address check on the parsed address instead of the raw string. bech32 decoding is case-insensitive, so two different strings can denote the same address.
  • Reject ed25519 keys of the wrong length with an error instead of panicking on the slice-to-array conversion, and reject nil validator entries.

2. PartSetHeader.Total was not bounded before canonicalization (TMNSC-282)

The converters copied the protobuf int64 PartSetHeader.Total into gno's bfttypes.PartSetHeader with a bare int cast and no bounds check, at four sites (commit block ID, each precommit block ID, header last block ID, and ConvertToGnoBlockID). None of the ValidateBasic paths cap it, since PartSetHeader.ValidateBasic is never invoked.

A precommit carrying a Total above the uint32 range therefore survived conversion and Commit.ValidateBasic, then panicked in gno's CanonicalizePartSetHeader when vote sign bytes were computed during commit verification. The panic is caught by the SDK's recovery middleware and surfaces as a deterministic transaction failure charged to the submitter, so there is no consensus or liveness impact. The upstream gno comment treats this panic as a fail-loud assertion and directs integrators to validate upstream, which is what this does.

Changes:

  • Add convertPartSetHeader, enforcing the [0, MaxBlockPartsCount] bound and 32-byte-or-empty hash size that gno's PartSetHeader.ValidateBasic applies, and route all four cast sites through it. Out-of-range values now return a typed ErrInvalidHeader.
  • ConvertToGnoBlockID now returns an error; both misbehaviour callers are updated.
  • Reject a precommit Type that does not fit gno's byte-sized SignedMsgType, so the conversion cannot truncate an out-of-range wire value into the precommit type.

State compatibility

These fixes are state-machine-breaking and must ship in a coordinated upgrade rather than as a patch release that nodes adopt independently. The changelog entries sit under State Breaking for that reason.

Behavior only differs for malformed client messages:

  • A header whose validator addresses are not derived from their public keys was previously accepted and is now rejected.
  • A header whose parts-header total is out of range previously failed with a recovered panic and now fails with a typed ErrInvalidHeader.

Legitimate gno headers are unaffected. Because the transaction error code and gas used feed into the results hash, a node on this binary and a node on the previous one would produce different results for such a message and diverge.

Tests

Validator set binding, in TestConvertToGnoValidatorSet_RejectsMalformedSets:

  • address not derived from pubkey (cross-checked against gno's NewValidatorSet, which panics on the same input)
  • one pubkey under several distinct valid addresses (the reported set shape)
  • duplicate address differing only in case
  • pubkey with invalid length, no panic
  • nil validator entry, no panic

Parts header bounds:

  • TestConvertPartSetHeader_Bounds exercises every cast site with -1, MaxBlockPartsCount + 1, MaxUint32 + 1 and MaxInt64 (rejected, no panic), 0, 1 and MaxBlockPartsCount (accepted), a 31-byte hash (rejected) and an empty hash (accepted).
  • TestConvertToGnoCommit_RejectsOutOfRangePrecommitType shows the narrowing conversion alone would map 258 to the precommit type, and that the converter now rejects it.
  • TestMisbehaviour_ValidateBasic_RejectsOversizedPrecommitPartsTotal reproduces the reported end-to-end path through Misbehaviour.ValidateBasic. It panics on main with PartSetHeader.Total (4294967296) out of canonical uint32 range and returns an error with this change.

All new cases fail on main and pass with this change. The full modules/10-gno suite and make lint pass.

…version

ConvertToGnoValidatorSet accepted any syntactically valid, unique address
string for a validator without checking that it is derived from the
validator's public key, which native gno enforces in its validator set
constructor. Since Validator.Bytes() excludes the address and commit sign
bytes exclude the validator address and index, a relayer could attach
distinct addresses to a single public key and have one signature counted
in several slots of a relayer-supplied validator set.

- Require address == pubkey.Address() for every validator.
- Key the duplicate-address check on the parsed address instead of the raw
  string, since bech32 decoding is case-insensitive.
- Reject ed25519 keys of the wrong length instead of panicking on the
  slice-to-array conversion, and reject nil validator entries.
The converters copied the protobuf int64 PartSetHeader.Total into the
vendored bfttypes.PartSetHeader with a bare int cast and no bounds check,
and none of the ValidateBasic paths cap it. A precommit carrying a Total
above the uint32 range therefore survived conversion and Commit.ValidateBasic,
then panicked in CanonicalizePartSetHeader when the vote sign bytes were
computed during commit verification. The panic is recovered by the SDK
and surfaces as a deterministic transaction failure, so the change only
improves the error type.

- Add convertPartSetHeader, enforcing the [0, MaxBlockPartsCount] bound and
  hash size that gno's PartSetHeader.ValidateBasic applies, and route the
  commit, precommit, header last-block-ID and block-ID conversions through it.
- ConvertToGnoBlockID now returns an error; both misbehaviour callers updated.
- Add converter bounds tests for every cast site and a misbehaviour
  ValidateBasic regression that previously panicked.
@clockworkgr clockworkgr changed the title fix(10-gno): bind validator addresses to their public keys during conversion fix(10-gno): bind validator addresses to pubkeys and bound PartSetHeader.Total during conversion Sep 10, 2026
…ests

- Reject a precommit Type above math.MaxUint8 in ConvertToGnoCommit, since
  the conversion to gno's byte-sized SignedMsgType would otherwise truncate
  a wire value such as 258 into PrecommitType and pass gno's own check.
- Clarify that presence of a block ID is checked with IsComplete, not
  ValidateBasic, in the ConvertToGnoBlockID doc comment.
- Refer to gno's CanonicalizePartSetHeader as upstream rather than vendored.
- Iterate the parts-header bounds test over a slice for stable subtest order,
  and assert the forged-set test names the first unbound address.

@giunatale giunatale left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couple nits, but overall looking good.

TY!

Comment thread CHANGELOG.md Outdated
Comment thread modules/10-gno/helpers.go Outdated
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.

2 participants