You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Ensure we don't construct finalize votes for rounds we have timed out on #280
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
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.
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.
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.
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.
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.
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.
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
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
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.
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
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.
Activity
yacovm commentedon Oct 16, 2025
we can send a finalize vote if we have finalized a descendant.
yacovm commentedon Sep 1, 2026
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
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.
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.
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.
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.
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.
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.
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
Recommended fix
Severity: HIGH
Status: Open
Category: Consensus safety violation
CWE: CWE-358
Repository: ava-labs/Simplex
Branch: main
Date created: 2026-08-21
samliok commentedon Oct 14, 2026
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.