Skip to content

Ensure we don't construct finalize votes for rounds we have timed out on #280

Closed

Description

@samliok

https://github.com/ava-labs/Simplex/blob/main/epoch.go#L1021

we never check if we have timed out on a round before re-constructing a finalize vote message

Activity

  1. yacovm commented on Oct 16, 2025

    @yacovm
    Collaborator

    we can send a finalize vote if we have finalized a descendant.

  2. yacovm commented on Sep 1, 2026

    @yacovm
    Collaborator

    Security review (2026-09-01) found a concrete exploit for this class of bug — constructing a finalize vote for a timed-out round enables a double-finalization fork. Full finding below for reference on this issue:


    Finalize vote sent for timed-out round enables double finalization fork

    Details

    Simplex safety relies on the invariant that an honest node never issues BOTH an empty vote and a finalize vote for the same round: a finalization quorum (2f+1 finalize votes) and an empty-notarization quorum (2f+1 empty votes) for round r must intersect in at least one honest node, so if a block is finalized in round r no empty notarization for r can exist, and hence no notarized chain can skip round r. The live path enforces this: doNotarized() refuses to broadcast a finalize vote when haveWeAlreadyTimedOutOnThisRound(r) is true.

    rebroadcastPastFinalizeVotes() breaks the invariant. For every round in e.rounds that has a notarization but no finalization, if the node has no finalize vote of its own stored, it constructs a brand-new signed finalize vote (constructFinalizeVoteMessage) and broadcasts it — with no check of whether the node timed out (empty-voted) that round. This function is reachable remotely in two ways: (1) the NotarizationTime timer fires it automatically whenever the node holds a notarized-not-finalized round and its round number does not advance for FinalizeRebroadcastTimeout (~6s) — a condition an adversary induces simply by withholding messages; (2) persistFinalization() invokes it directly whenever a valid finalization for a future sequence is received, which an attacker can trigger by forwarding any legitimate future finalization.

    A node reaches the vulnerable state through normal protocol operation: it votes for block B in round r, times out and broadcasts an empty vote for r (timedOut=true), then receives a valid notarization N_r for B (handleNotarizationMessage accepts notarizations after timeout by design). doNotarized correctly withholds the finalize vote, leaving round r notarized-not-finalized. When either trigger fires, the node signs and broadcasts a finalize vote for round r anyway.

    Additionally, persistFinalization calls deleteOldEmptyVotes(finalizedRound) before rebroadcastPastFinalizeVotes, deleting the EmptyVoteSet (and its timedOut flag) for all rounds below a not-yet-committed future finalization — erasing the only record that would allow any timed-out check to work for those rounds.

    Consequence: an adversary controlling one Byzantine validator plus message scheduling can collect from the victim both an empty vote for round r and a finalize vote for round r's block B. Combining the finalize votes of the victim, an honest node that never timed out, and the attacker yields a finalization F_r of B (seq s); combining the empty votes of the victim, another timed-out honest node, and the attacker yields an empty notarization EN_r. EN_r lets a round-(r+1) proposal build on B_{r-1} with the same seq s (blockDependencies accepts EN_r as the dependency for skipping round r), which can then be notarized and finalized — two different blocks finalized at the same sequence: a ledger fork with only f=1 Byzantine validator.

    Evidence

    1. simplex/epoch.go:1350–1388
      rebroadcastPastFinalizeVotes iterates all notarized-but-not-finalized rounds and, when the node has no stored finalize vote for a round, constructs and broadcasts a fresh finalize vote (constructFinalizeVoteMessage) WITHOUT checking haveWeAlreadyTimedOutOnThisRound(r). A node that empty-voted round r therefore signs a finalize vote for the same round r.
    2. simplex/epoch.go:3025–3030
      Contrast: the live path doNotarized correctly enforces the protocol invariant — it refuses to broadcast a finalize vote for a round the node already timed out on (haveWeAlreadyTimedOutOnThisRound). This is the safety rule that rebroadcastPastFinalizeVotes bypasses.
    3. simplex/epoch.go:1293–1295
      persistFinalization calls deleteOldEmptyVotes(finalization.Finalization.Round) which deletes the EmptyVoteSet (including the timedOut flag) for all rounds below the finalized round, erasing the only record that the node empty-voted those rounds, before rebroadcastPastFinalizeVotes is invoked at line 1320. So even adding the timed-out check to rebroadcastPastFinalizeVotes alone would be defeated by this state erasure for still-uncommitted rounds.
    4. simplex/epoch.go:1320–1322
      Remote trigger add spec writeup #1: when a valid finalization for a future sequence is received (attacker can forward a legitimate one), persistFinalization directly invokes rebroadcastPastFinalizeVotes, causing the victim to emit the forbidden finalize vote on demand.
    5. simplex/epoch.go:248–261
      Remote trigger Implement record serialization and deserialization #2: initOldestNotFinalizedNotarization wires rebroadcastPastFinalizeVotes to the NotarizationTime timer, which fires automatically whenever the node has a notarized-not-finalized round and makes no round progress for FinalizeRebroadcastTimeout (default 6s). An attacker merely needs to withhold messages to stall the victim.
    6. simplex/util.go:237–274
      NotarizationTime.CheckForNotFinalizedNotarizedBlocks implements the stall detection that invokes rebroadcastFinalizationVotes (the rebroadcast callback) when the oldest notarized-not-finalized round is unchanged for the timeout period.
    7. simplex/epoch.go:2696–2738
      triggerEmptyBlockNotarization is where the node signs and broadcasts its empty vote for round r and sets emptyVotes[r].timedOut = true. This is the first half of the mutually-exclusive pair (empty vote XOR finalize vote per round) whose exclusion rebroadcastPastFinalizeVotes violates.

    Impact

    The leaked finalize vote lets a finalization and an empty notarization coexist for the same round, defeating the quorum-intersection argument that guarantees a unique finalized block per sequence. With one Byzantine validator, two conflicting blocks can be finalized at the same sequence on different honest nodes — total loss of consensus integrity (ledger fork / double finalization). A fork also permanently diverges honest nodes' storage, effectively halting coherent operation of the chain (availability).

    Reproduction steps

    1. Attacker is a single Byzantine validator on the consensus network who also influences message delivery timing (standard BFT adversary). It maneuvers the victim into the timed-out-then-notarized state for round r (deliver proposal, let victim time out, then deliver the notarization), then either waits ~6s for the NotarizationTime timer or forwards a valid future-sequence finalization to trigger persistFinalization. The victim then signs the forbidden finalize vote. No cryptographic mechanism must be defeated; conditions are specific runtime state the attacker orchestrates.

    Recommended fix

    1. rebroadcastPastFinalizeVotes constructs and broadcasts finalize votes for notarized-not-finalized rounds without checking whether the node already timed out (empty-voted) on those rounds, violating the empty-vote/finalize-vote exclusivity that consensus safety depends on. Fix criteria: For every path that emits a finalize vote (including rebroadcast paths), the node must never sign or broadcast a finalize vote for a round it has empty-voted. Verify that after a node times out on round r and later receives r's notarization, neither the NotarizationTime timer nor receipt of a future-sequence finalization causes it to emit a finalize vote for r.
    2. persistFinalization deletes empty-vote sets (including the timedOut flag) for all rounds below a future, not-yet-committed finalization before invoking the rebroadcast logic, erasing the state needed to enforce the exclusivity check for rounds that are still uncommitted. Fix criteria: The record that the node empty-voted a round must remain available (in memory and across restarts via the WAL) for as long as the node could still be asked to finalize-vote that round, i.e., until the round's outcome is committed. Verify that timed-out state for uncommitted rounds survives receipt of future-sequence finalizations.

    Severity: HIGH
    Status: Open
    Category: Consensus safety violation
    CWE: CWE-358
    Repository: ava-labs/Simplex
    Branch: main
    Date created: 2026-08-21


  3. samliok commented on Oct 14, 2026

    @samliok
    CollaboratorAuthor

    The issue described above is actually not a problem. We can actually broadcast a vote finaleVote and emptyVote for a round, if that rounds block was in the finalized chain. This PR, addresses a similar bug #623.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions