ava-labs / ava-labs/Simplex

Legitimate Telock/new-epoch sequence reuse triggers conflicting-finalization permanent halt

Closed
#545 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
In the Simplex epoch-transition design, after the sealing block at seq s, Telocks of the old epoch occupy seqs s+1..s+x, may themselves be finalized by the old epoch's quorum (each round's finalize votes are aggregated into a per-round Finalization QC and broadcast, simplex/epoch.go persistFinalization), and are then pruned; the first blocks of the new epoch REUSE those same sequence numbers with finalizations signed by the new epoch's validator set (msm/msm.go diagram; EpochAwareStorage.Index explicitly ignores finalized Telocks from previous epochs). Therefore two different, individually QC-valid finalizations legitimately exist for the same sequence number.

The non-validator's handleFinalization does not account for this. It verifies each incoming finalization against the validator set of whichever epoch the finalization's BlockHeader declares - both the old epoch and the new epoch are simultaneously registered at the boundary (epoch s is added by maybeValidateNextEpoch as soon as the sealing block is seen; removeOldEpochs deletes only epochs strictly below the last indexed block's epoch). It stores the first finalization in incompleteSequences[seq]; when a second, byte-different verified finalization for the same seq arrives, it declares 'conflicting finalizations', sets n.haltedError, and HandleMessage permanently rejects all subsequent messages (non_validator.go:361-376, 149-151). The check was written as a Byzantine-equivocation fuse ('sanity check: should never happen'), but it is reachable with two entirely honest artifacts, and the code never consults the epoch field that would distinguish them.

Exploit: any peer that can deliver messages to the non-validator sends (1) the finalization of a finalized Telock at seq s+1 (declaring the old epoch) and (2) the finalization of the new epoch's first block at seq s+1 (declaring epoch s). Both are genuine, broadcast network artifacts. As long as the victim's nextSeqToCommit is <= s+1 (a synced node at the boundary, or any node catching up across the boundary later - the attacker can register epoch s for it by replaying the genuine sealing-block quorum round in a ReplicationResponse), both messages pass isAccepted, the epochs lookup, and VerifyQC; the second hits the mismatch branch and the node halts until process restart. If the halt is triggered before the sealing block is committed, the replay works again after every restart, since newEpochs re-registers the old epoch and the commit height never advances. The same halt can occur without an attacker when a Telock finalization broadcast arrives (its block lost) before the new-epoch finalization. Validators are unaffected: their storeFinalization is keyed by round, handles header mismatch by deleting the round and re-requesting, and the Epoch instance is restarted with a garbage-collected WAL at the boundary, whereas a non-validator is deliberately not restarted (instance.go transitionEpochNonValidator).

## Evidence
1. [nonvalidator/non\_validator.go:361–376](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L361-L376)
handleFinalization treats any two byte-different verified finalizations for the same sequence as a fatal equivocation and sets haltedError. The comparison ignores that across an epoch boundary two DIFFERENT legitimate finalizations exist for the same seq: the old epoch's finalized Telock and the new epoch's first block (Telocks are pruned and their seqs reused).
2. [nonvalidator/non\_validator.go:149–151](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L149-L151)
Once haltedError is set, every subsequent HandleMessage call returns the error; the non-validator permanently stops following the chain until process restart.
3. [msm/msm.go:806–827](https://github.com/ava-labs/Simplex/blob/main/msm/msm.go#L806-L827)
Design diagram: Telocks occupy seqs s+1..s+x in the old epoch and 'get pruned'; the first block of the new epoch reuses seq s+1. Hence two distinct quorum-signed finalizations legitimately exist for the same sequence number across the boundary.
4. [adapters.go:59–76](https://github.com/ava-labs/Simplex/blob/main/adapters.go#L59-L76)
EpochAwareStorage.Index explicitly receives finalized Telocks from previous epochs and discards them ('we ignore it and do not index it'), proving finalized Telocks with reused sequence numbers are an expected honest-protocol artifact, and that NumBlocks (nextSeqToCommit) stays at s+1 while both finalizations are live.
5. [nonvalidator/non\_validator.go:323–337](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L323-L337)
Both finalizations verify: the Telock finalization against epochs[oldEpoch] and the new-epoch block's finalization against epochs[s] (registered by maybeValidateNextEpoch when the sealing block was processed); removeOldSequencesAndEpochs only deletes epochs strictly below the indexed block's epoch, so both sets are simultaneously registered at the boundary.
6. [instance.go:536–553](https://github.com/ava-labs/Simplex/blob/main/instance.go#L536-L553)
Unlike validators, a non-validator instance is NOT restarted on epoch change ('Skipping restarting a non-validator because I am not a validator yet'), so its incompleteSequences state persists across the boundary where the seq reuse happens.

## Impact
The non-validator permanently stops processing all messages (haltedError checked at the top of HandleMessage) until operator restart - total loss of its chain-following function. When the halt is triggered before the sealing block commits, the same two replayed messages re-halt it after every restart since its commit height never advances; if triggered after, a restart clears the old epoch registration and the node recovers, but manual intervention was still forced. No incorrect data is indexed, so integrity and confidentiality are unaffected.

## Reproduction steps
1. Attacker is any network peer able to deliver two Finalization messages to the non-validator (handleFinalization performs no sender authorization; 'from' is only logged). Ammunition is two genuine finalizations recorded at an epoch boundary where at least one Telock was finalized - a normal protocol occurrence, but runtime state outside attacker control (AT:PRESENT). The victim must have nextSeqToCommit at or below the reused sequence, which holds at every boundary crossing, live or during catch-up; the attacker can even register the new epoch for the victim by replaying the genuine sealing-block quorum round. The two messages then deterministically set haltedError.

## Recommended fix
The conflicting-finalization fatal check assumes at most one valid finalization per sequence, but the epoch-transition design legitimately produces two (finalized Telock of the old epoch and the first block of the new epoch reusing its seq). Fix criteria: The non-validator must tolerate two verified finalizations for the same sequence when they belong to different epochs across a sealing boundary (e.g., prefer the newer epoch's finalization or discard superseded Telock finalizations), reserving the halt for genuine same-epoch equivocation. Verify that replaying a finalized Telock finalization plus the new epoch's first-block finalization no longer halts the node and the node commits the new-epoch block.

---
**Severity:** HIGH
**Status:** Open
**Category:** Reachable assertion
**CWE:** [CWE-617](https://cwe.mitre.org/data/definitions/617.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/non_validator.go:323-376 and 149-151, then trace epoch registration and transition behavior in instance.go:536-553 and adapters.go:59-76. Reproduce delivery of the old-epoch Telock finalization followed by the new-epoch finalization for the reused sequence, and confirm the node no longer halts and commits the new-epoch block while same-epoch conflicts remain fatal.

Written by the indexing model from the issue text.

Assessment

Tech stack
go
Domain
distributed-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.