Details
handleReplicationResponse bounds incoming replication data to MaxRoundWindow rounds ahead of e.round, but waives the bound for any QuorumRound whose block claims seq == nextSeqToCommit (epoch.go:3443). The waiver is intended to let a far-behind node accept THE next block regardless of round gap, but it accepts EVERY notarized fork of that sequence.
Such forks are real, replayable artifacts: a round may legitimately produce both a block notarization and an empty notarization (honest nodes may vote for a proposal and later time out), in which case the next round's leader proposes a different block with the SAME seq. During a period of degraded operation (Byzantine leaders proposing unverifiable/withheld blocks, timeout races), each round r, r+1, r+2, ... can yield a distinct notarized block with seq S plus an empty notarization. A single Byzantine validator can later replay this entire run to a victim that is catching up (nextSeqToCommit == S) while withholding newer finalizations. Every fork round passes the bypass, is QC-verified, and is stored with its full block in ReplicationState.rounds (storeRound has no size cap; the only pruning trigger is observing a finalization at a higher round, which the attacker's channel withholds).
Consequences: (1) unbounded memory growth proportional to the fork-run length times block size; (2) once maybeAdvanceRoundFromEmptyNotarizations jumps e.round to the run's highest round, every fork block is scheduled for VM verification and its notarization is persisted to the WAL, consuming CPU and disk with useless artifacts; (3) with empty-notarization dependencies withheld, pending tasks accumulate against the 500-task cap of the BlockDependencyManager, after which processNotarizedBlock returns ErrTooManyPendingVerifications, which propagates out of processReplicationState/handleReplicationResponse/HandleMessage to the embedding application (unlike the proposal path, which swallows this error).
This is adjacent to, but distinct from, the unbounded processing of resp.LatestRound/resp.LatestSeq at epoch.go:3454/3458 (already recorded): the sink here is the Data-loop window check's sequence exception. The growth is bounded by the amount of genuine fork history the adversary can accumulate, so impact is rated as bounded degradation rather than a total outage.
Evidence
- simplex/epoch.go:3442–3447
The bypass: the MaxRoundWindow bound on incoming replication rounds is waived whenever data.GetSequence() == nextSeqToCommit (line 3443), with no alternative bound. The seq check at 3436 applies only to finalization-bearing entries, so a (Block, Notarization) QuorumRound with seq == nextSeqToCommit is admitted regardless of how far its round exceeds e.round + MaxRoundWindow. Since a round can legitimately carry both a block notarization and an empty notarization, consecutive stall rounds can each notarize a different block with the same seq, giving the attacker an arbitrary number of distinct admissible QuorumRounds, each carrying a full block.
- simplex/replication_state.go:141–160
storeRound keeps one entry per distinct round key with no cap; every same-seq fork round admitted by the bypass is retained (block bytes included) in the rounds map.
- simplex/replication_state.go:179–191
The only drop-gate before storage: rounds at or below the highest OBSERVED finalization round are discarded. During an ongoing stall no finalization for the forked seq exists anywhere, so the gate passes for every node; for historical fork runs it passes for a victim that has not yet observed a recent finalization (fresh node, or one whose attacker-controlled peer withholds finalizations).
- simplex/epoch.go:2114–2120
Downstream amplification: each fork block reachable at or below e.round is turned into a verification task with dependencies. Dependencies count toward the 500-task cap of the BlockDependencyManager, and each completed fork task VM-verifies the block and persists a notarization record to the WAL (createNotarizedBlockVerificationTask), so replayed fork runs also consume WAL space and VM verification work.
- simplex/epoch.go:3592–3610
maybeAdvanceRoundFromEmptyNotarizations accelerates the attack: GetBlockWithSeq(expectedSeq) returns one of the stored same-seq fork blocks and e.round is jumped up to that block's round without possessing the intervening empty notarizations, bringing the flood of stored fork rounds at or below e.round so processReplicationState feeds them into processNotarizedBlock.
- simplex/epoch.go:3557–3570
processReplicationState propagates the error from processNotarizedBlock: when the block-verification scheduler is full (ErrTooManyPendingVerifications after 500 pending fork blocks with withheld empty-notarization dependencies), the error escapes handleReplicationResponse and Epoch.HandleMessage, surfacing an attacker-triggered error to the embedding application (unlike the proposal path at epoch.go:1919-1921, which swallows it).
Impact
Memory growth in the rounds map (full blocks), WAL growth from persisted fork notarizations, wasted VM verification work, and a propagated ErrTooManyPendingVerifications from HandleMessage once 500 pending verifications accumulate. All effects are bounded by the length of genuine fork history the adversary can manufacture and replay, so this degrades rather than destroys the node's service; no confidentiality or integrity effect (all artifacts are QC-valid).
Reproduction steps
- The replayer needs only validator-set membership and a single message channel to the victim. However, the ammunition must first exist: a run of same-seq notarized fork blocks requires a real period in which consecutive rounds each notarized a different block for the same sequence (leader misbehavior plus timeout races), and the victim must be behind with nextSeqToCommit equal to the forked sequence and without newer observed finalizations. These are runtime/history conditions outside the attacker's direct control.
Recommended fix
The MaxRoundWindow admission check on replicated rounds is fully waived for any block claiming seq == nextSeqToCommit, and the rounds map imposes no cap on the number of same-seq entries retained, so replayed fork history bypasses the memory bound the window was added to enforce. Fix criteria: Replication ingestion must enforce an upper bound on retained non-finalized QuorumRounds (e.g., cap total entries and/or retain at most one or a bounded few candidates per sequence, preferring the highest round), such that replaying an arbitrarily long same-seq fork run cannot grow the rounds map, WAL, or verification queue beyond a fixed bound. Verify that ingesting N >> MaxRoundWindow valid same-seq fork rounds keeps memory and pending verification tasks bounded and never surfaces a scheduler-capacity error out of HandleMessage.
Severity: LOW
Status: Open
Category: Resource exhaustion
CWE: CWE-770
Repository: ava-labs/Simplex
Branch: main
Date created: 2026-08-21
Details
handleReplicationResponse bounds incoming replication data to MaxRoundWindow rounds ahead of e.round, but waives the bound for any QuorumRound whose block claims seq == nextSeqToCommit (epoch.go:3443). The waiver is intended to let a far-behind node accept THE next block regardless of round gap, but it accepts EVERY notarized fork of that sequence.
Such forks are real, replayable artifacts: a round may legitimately produce both a block notarization and an empty notarization (honest nodes may vote for a proposal and later time out), in which case the next round's leader proposes a different block with the SAME seq. During a period of degraded operation (Byzantine leaders proposing unverifiable/withheld blocks, timeout races), each round r, r+1, r+2, ... can yield a distinct notarized block with seq S plus an empty notarization. A single Byzantine validator can later replay this entire run to a victim that is catching up (nextSeqToCommit == S) while withholding newer finalizations. Every fork round passes the bypass, is QC-verified, and is stored with its full block in ReplicationState.rounds (storeRound has no size cap; the only pruning trigger is observing a finalization at a higher round, which the attacker's channel withholds).
Consequences: (1) unbounded memory growth proportional to the fork-run length times block size; (2) once maybeAdvanceRoundFromEmptyNotarizations jumps e.round to the run's highest round, every fork block is scheduled for VM verification and its notarization is persisted to the WAL, consuming CPU and disk with useless artifacts; (3) with empty-notarization dependencies withheld, pending tasks accumulate against the 500-task cap of the BlockDependencyManager, after which processNotarizedBlock returns ErrTooManyPendingVerifications, which propagates out of processReplicationState/handleReplicationResponse/HandleMessage to the embedding application (unlike the proposal path, which swallows this error).
This is adjacent to, but distinct from, the unbounded processing of resp.LatestRound/resp.LatestSeq at epoch.go:3454/3458 (already recorded): the sink here is the Data-loop window check's sequence exception. The growth is bounded by the amount of genuine fork history the adversary can accumulate, so impact is rated as bounded degradation rather than a total outage.
Evidence
The bypass: the MaxRoundWindow bound on incoming replication rounds is waived whenever data.GetSequence() == nextSeqToCommit (line 3443), with no alternative bound. The seq check at 3436 applies only to finalization-bearing entries, so a (Block, Notarization) QuorumRound with seq == nextSeqToCommit is admitted regardless of how far its round exceeds e.round + MaxRoundWindow. Since a round can legitimately carry both a block notarization and an empty notarization, consecutive stall rounds can each notarize a different block with the same seq, giving the attacker an arbitrary number of distinct admissible QuorumRounds, each carrying a full block.
storeRound keeps one entry per distinct round key with no cap; every same-seq fork round admitted by the bypass is retained (block bytes included) in the rounds map.
The only drop-gate before storage: rounds at or below the highest OBSERVED finalization round are discarded. During an ongoing stall no finalization for the forked seq exists anywhere, so the gate passes for every node; for historical fork runs it passes for a victim that has not yet observed a recent finalization (fresh node, or one whose attacker-controlled peer withholds finalizations).
Downstream amplification: each fork block reachable at or below e.round is turned into a verification task with dependencies. Dependencies count toward the 500-task cap of the BlockDependencyManager, and each completed fork task VM-verifies the block and persists a notarization record to the WAL (createNotarizedBlockVerificationTask), so replayed fork runs also consume WAL space and VM verification work.
maybeAdvanceRoundFromEmptyNotarizations accelerates the attack: GetBlockWithSeq(expectedSeq) returns one of the stored same-seq fork blocks and e.round is jumped up to that block's round without possessing the intervening empty notarizations, bringing the flood of stored fork rounds at or below e.round so processReplicationState feeds them into processNotarizedBlock.
processReplicationState propagates the error from processNotarizedBlock: when the block-verification scheduler is full (ErrTooManyPendingVerifications after 500 pending fork blocks with withheld empty-notarization dependencies), the error escapes handleReplicationResponse and Epoch.HandleMessage, surfacing an attacker-triggered error to the embedding application (unlike the proposal path at epoch.go:1919-1921, which swallows it).
Impact
Memory growth in the rounds map (full blocks), WAL growth from persisted fork notarizations, wasted VM verification work, and a propagated ErrTooManyPendingVerifications from HandleMessage once 500 pending verifications accumulate. All effects are bounded by the length of genuine fork history the adversary can manufacture and replay, so this degrades rather than destroys the node's service; no confidentiality or integrity effect (all artifacts are QC-valid).
Reproduction steps
Recommended fix
The MaxRoundWindow admission check on replicated rounds is fully waived for any block claiming seq == nextSeqToCommit, and the rounds map imposes no cap on the number of same-seq entries retained, so replayed fork history bypasses the memory bound the window was added to enforce. Fix criteria: Replication ingestion must enforce an upper bound on retained non-finalized QuorumRounds (e.g., cap total entries and/or retain at most one or a bounded few candidates per sequence, preferring the highest round), such that replaying an arbitrarily long same-seq fork run cannot grow the rounds map, WAL, or verification queue beyond a fixed bound. Verify that ingesting N >> MaxRoundWindow valid same-seq fork rounds keeps memory and pending verification tasks bounded and never surfaces a scheduler-capacity error out of HandleMessage.
Severity: LOW
Status: Open
Category: Resource exhaustion
CWE: CWE-770
Repository: ava-labs/Simplex
Branch: main
Date created: 2026-08-21