ava-labs / ava-labs/Simplex

Stale sealing-block votes from ex-validators let attackers install forged validator sets on non-validators

Open
#543 0 comments 0 reactions 0 assignees View on GitHub
high security
Dominant language
Go
Stars
22
Forks
4
Avg merge
2d 14h
Merged PRs (30d)
34

Description

## Details
Non-validator (syncing/RPC) nodes learn future epochs' validator sets from sealing blocks. For a sealing block of an epoch it cannot yet verify, non_validator.go accepts it if f+1 validators vouch for the same block digest (epochDigestCounter.collectedSealingBlockInfo). The membership check (validators.Contains(from)) runs only when a vote arrives; votes are stored in sealingBlockResponses and are never invalidated or re-checked when the validator set changes (removeOldEpochs prunes only committed epochs, so buckets for future seqs persist). The threshold F(|current set|)+1 is computed at trigger time.

Consequently, the 'at least one honest voucher' property fails across validator-set rotations. Example (n=4, f=1, fully within the <=f-corrupt-per-epoch fault model with adaptive corruption): corrupt validator V sends the victim an unsolicited ReplicationResponse containing a forged 'sealing block' B_fake at future seq S (arbitrary decoded BLS keys/weights in its BlockValidationDescriptor, an unknown Epoch header field, and a garbage finalization whose digest merely matches - VerifyQCConsistentWithBlock checks only digest consistency, not signatures). The vote (V -> digest(B_fake)) is recorded. Later V rotates out and the attacker corrupts one current validator V2. V2 re-sends B_fake: counts[digest]=2 >= F(4)+1=2, and maybeValidateNextEpoch installs epochs[S] = newEpochMetadata(B_fake.SealingBlockInfo) - attacker keys and weights - without any QC ever being verified. Larger sets need f current corrupt + enough stale votes (always >= 1, e.g., after a set shrink or rotation).

Impact: epochs[S] is the key material for verifying all QCs of 'epoch S'. processReplicationState and handleFinalization look up n.epochs by the epoch number the block/finalization ITSELF claims, with no binding between sequence and active epoch. The attacker immediately serves blocks claiming Epoch=S at the victim's next sequences to commit with finalizations signed by its forged keys; they pass VerifyQC, pass Verify(OnlyVM) trivially (no inner block), and are indexed - a full forged-chain takeover of the non-validator, which can also flip its comm validator set via a forged sealing block (EpochAwareStorage.Index -> onEpochChange). Alternatively the attacker can (a) pre-poison the epoch number of the real future epoch S so genuine QCs later fail to verify, or (b) install an empty validator set, causing a division-by-zero panic in LeaderForRound, or (c) send two conflicting forged finalizations to set haltedError and permanently halt the node.

## Evidence
1. [nonvalidator/epochs.go:168–206](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/epochs.go#L168-L206)
collectedSealingBlockInfo validates the sender against the CURRENT validator set only at vote-recording time (validators.Contains(from), line 175) and stores the vote in sealingBlockResponses keyed by (seq, sender) (line 190). At threshold time it counts ALL stored votes, including votes recorded earlier by nodes since rotated out of the validator set, against a threshold F(current set size)+1 recomputed at every call (line 182). The f+1 threshold is meant to guarantee at least one honest voucher among CURRENT validators; counting stale votes from ex-validators breaks that guarantee: f current corrupt validators plus one corrupt ex-validator (or fewer, after a set shrink) reach the threshold with zero honest vouchers.
2. [nonvalidator/non\_validator.go:511–541](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L511-L541)
handleQrFromUnknownEpoch invokes collectedSealingBlockInfo on a completely UNVERIFIED QuorumRound (its finalization QC is never verified on this path - only VerifyQCConsistentWithBlock, a digest-consistency check on attacker-supplied data, common/msg.go:338). When the counter returns true, maybeValidateNextEpoch installs the epoch metadata from the CURRENT sender's block, i.e., the forged sealing block's decoded BLS keys and weights, and the unverified QR is stored in the replicator. There is no upper bound on the block's Seq on this path, so the attacker can plant votes in a far-future bucket no honest validator will ever vote in.
3. [nonvalidator/non\_validator.go:284–298](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L284-L298)
maybeValidateNextEpoch installs n.epochs[block.Seq] = newEpochMetadata(...) from the block's decoded SealingBlockInfo (attacker-chosen NodeIDs, BLS keys, weights decoded straight from the block's BlockValidationDescriptor in msm/block.go:114-135 - or an empty set). This map is the key material used to verify all finalization QCs of that epoch. It also refuses later re-installation (alreadyValidated), so pre-poisoning the real next epoch number permanently blocks the genuine descriptor until restart.
4. [nonvalidator/epochs.go:208–214](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/epochs.go#L208-L214)
removeOldEpochs only prunes response buckets with seq below the last committed epoch; buckets for future sequence numbers (where the attacker plants stale votes) persist indefinitely across validator-set changes, and votes are never re-validated or cleared when the set changes.
5. [nonvalidator/non\_validator.go:435–464](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L435-L464)
Exploitation: processReplicationState verifies the finalization QC of the next block to commit against n.epochs[block.BlockHeader().Epoch] - the epoch the BLOCK ITSELF claims, with no check that this epoch is the one active at that sequence. Once the forged epochs[S] is installed, the attacker feeds blocks claiming Epoch=S at the very next sequences to commit, with finalizations signed by its own forged keys (VerifyQC checks eligibility, quorum, and BLS signatures entirely against the forged epoch's node list, simplex/util.go:54-77); they verify and are committed.
6. [nonvalidator/non\_validator.go:313–337](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L313-L337)
handleFinalization likewise selects the QC-verification key set by the epoch number the attacker-supplied finalization itself claims (n.epochs[bh.Epoch], line 323) and verifies with VerifyQC at line 331; after poisoning, attacker-signed finalizations for the forged epoch are accepted.
7. [nonvalidator/non\_validator.go:362–373](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L362-L373)
Availability: two conflicting attacker-signed finalizations for the same seq (both passing VerifyQC against the forged epoch keys) set n.haltedError, which HandleMessage (lines 149-151) returns on every subsequent message - the node is permanently halted until restart.
8. [nonvalidator/non\_validator.go:174–188](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L174-L188)
Crash variant: handleBlock looks up the epoch the block claims (line 174) and calls simplex.LeaderForRound(epoch.nodes.NodeIDs(), bh.Round) (line 185) with no sender validation. If the attacker installed a forged epoch with an EMPTY validator set, LeaderForRound (simplex/epoch.go:3684-3687, nodes[r%uint64(n)]) panics with integer divide by zero on any peer's BlockMessage claiming that epoch.
9. [external.go:56–62](https://github.com/ava-labs/Simplex/blob/main/external.go#L56-L62)
The committed forged blocks are verified with OnlyVM; for blocks without an inner block (telock-style), Verify returns immediately with no checks at all, so the forged blocks are indexed into storage unconditionally once their (forged-key) QC passes.
10. [instance.go:156–174](https://github.com/ava-labs/Simplex/blob/main/instance.go#L156-L174)
Establishes the staleness premise and the amplification: the non-validator's Comm validator set (the counter's latestValidatorSetRetriever, non_validator.go:116) is rotated by EpochAwareStorage.Index -> onEpochChange -> comm.SetValidators on every committed sealing block, so recorded votes go stale on each rotation; and once a FORGED sealing block is committed, onEpochChange installs the attacker's validator set as the comm set.

## Impact
The victim non-validator installs attacker-chosen BLS keys/weights as the QC-verification key material for a (future or fabricated) epoch, then accepts and indexes an attacker-forged finalized chain (integrity: total loss of the node's core guarantee). The attacker can alternatively brick the node: poison the real next epoch number so genuine QCs fail, crash it via an empty validator set (mod-0 panic in LeaderForRound), or halt it via conflicting forged finalizations (haltedError). Restart heals the poisoned map but the attack is repeatable.

## Reproduction steps
1. Attacker sends unsolicited ReplicationResponse messages to the victim non-validator over the network. Requires controlling one validator identity that is later rotated out (its vote is planted while it is still a member) and one current validator identity at trigger time - never more than f concurrently corrupt validators, so within the protocol's fault model via adaptive corruption. Requires a validator-set rotation and a victim running as a non-validator whose comm set contained the first identity when the vote was planted.

## Recommended fix
1. epochDigestCounter counts sealing-block digest votes recorded under earlier validator sets: membership is checked only at arrival time and recorded votes are never invalidated on validator-set change, so the F(n)+1 threshold can be met without any honest current validator vouching. Fix criteria: At threshold evaluation, only votes whose senders are members of the CURRENT validator set may count toward F(current)+1 (re-validate membership at count time, or purge sealingBlockResponses whenever the validator set changes). Verify that the example attack (one stale ex-validator vote plus f current corrupt votes) can no longer reach the threshold, i.e., epoch installation always includes at least one honest current-validator voucher.
2. Epoch key material is installed from a block whose finalization QC was never verified (handleQrFromUnknownEpoch), and QC verification on the commit and finalization paths selects the key set by the epoch number the attacker-supplied block/finalization itself claims, with no binding between a sequence number and the epoch actually active at it. Fix criteria: A block or finalization claiming epoch E is only verified against epochs[E] if E is consistent with the epoch active at that sequence (e.g., E equals the last installed epoch whose sealing-block seq is below the block's seq); blocks claiming unrelated epochs are rejected before QC verification and indexing. Verify that after poisoning epochs[S] the attacker still cannot get blocks committed at sequences belonging to a different epoch.

---
**Severity:** HIGH
**Status:** Open
**Category:** Incorrect authorization
**CWE:** [CWE-863](https://cwe.mitre.org/data/definitions/863.html)
**Repository:** ava-labs/Simplex
**Branch:** main
**Date created:** 2026-08-21

---

Contributor guide

No contributing guide indexed for this repository

Research direction

Start with nonvalidator/epochs.go, especially collectedSealingBlockInfo and removeOldEpochs, then trace handleQrFromUnknownEpoch and maybeValidateNextEpoch in nonvalidator/non_validator.go. Follow the QC lookups in processReplicationState and handleFinalization, and use the reproduction steps to verify that stale votes and epoch claims cannot install or authorize forged validator sets.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
distributed-systems, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.