Skip to content

seq==nextSeqToCommit exception admits unlimited same-seq fork rounds past the replication window #562

Description

@yacovm

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

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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

  1. 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


Activity

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

    securitySecurity finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions