ava-labs / ava-labs/Simplex

IsSequenceScheduled misses queued and running tasks, allowing duplicate finalized-block verification tasks

Open
#550 0 comments 0 reactions 0 assignees View on GitHub
medium security
Dominant language
Go
Stars
22
Forks
4
Avg merge
2d 14h
Merged PRs (30d)
34

Description

## Details
The nonvalidator's only defense against scheduling multiple verification tasks for the same sequence is BlockDependencyManager.IsSequenceScheduled (common/block_scheduler.go), which inspects only the dependency list. A task for the next sequence to commit is always admitted with prev == nil (its predecessor is already accepted), so it is pushed directly into the BasicScheduler channel and is never present in the dependency list; likewise a task whose dependency resolved has moved to the channel, and the currently executing task is in neither structure. IsSequenceScheduled therefore returns false for a sequence whose task is queued or running.

An unauthenticated peer exploits this by replaying the current sequence's legitimate (block, finalization) quorum round in ReplicationResponse messages, which are accepted from any sender with no request-response correlation. Each message flows: processQuorumRound (passes: the data is QC-valid public chain data, isAccepted is false until Index completes) -> StoreQuorumRound/storeSequence (re-admitted because the previous scheduling pass called DeleteSeq) -> processReplicationState -> DeleteSeq -> scheduleNewFinalizedBlockTask, where IsSequenceScheduled misses the in-flight task and a duplicate is enqueued. One duplicate task per replayed message, bounded only by the 500-slot shared queue.

Consequences: (a) duplicate tasks consume slots of the single verification queue shared by the direct-message and replication paths, driving the pending count toward the maxDeps=500 admission limit and causing legitimate scheduling to fail with ErrTooManyPendingVerifications; this is the enabling primitive for the companion finding nonvalidator-deleteseq-before-schedule-full-queue-permanent-halt, where a full queue at task completion permanently drops a sequence and halts the node. (b) Duplicates accumulate while the head task's verification is in flight (the scheduler is single-goroutine, so they run only after it completes). If the head verification succeeded, duplicates hit the OneTimeVerifier cache and drain cheaply; if it failed, the failure is not cached, so each duplicate re-executes a full VM verification before the defensive nextSeqToCommit check and sends an additional ResendFinalizationRequest to a random signer - a per-message CPU/network amplification in the verification-failure case. Double-indexing itself is prevented by the in-task nextSeqToCommit re-check and by the single-goroutine scheduler.

This is the nonvalidator analog of the validator-path scheduling flaws recorded at simplex/epoch.go:1919/3548; the sink here (non_validator.go:404/416 with block_scheduler.go:124) is distinct.

## Evidence
1. [common/block\_scheduler.go:124–135](https://github.com/ava-labs/Simplex/blob/main/common/block_scheduler.go#L124-L135)
IsSequenceScheduled iterates only bs.dependencies. Tasks that were scheduled with no dependencies (pushed directly into the BasicScheduler channel), tasks whose dependencies already resolved, and the currently executing task are all invisible to this check.
2. [nonvalidator/non\_validator.go:402–417](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L402-L417)
scheduleNewFinalizedBlockTask relies solely on IsSequenceScheduled to enforce the stated invariant ('Avoid scheduling more than one task'). For seq == nextSeqToCommit, isAccepted(seq-1) is true so prev is nil and the task bypasses the dependency list entirely, making the guard ineffective for exactly the sequence the replication path schedules.
3. [nonvalidator/non\_validator.go:490–508](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L490-L508)
processQuorumRound accepts a replayed, QC-valid quorum round for nextSeqToCommit from ANY sender (isAccepted only becomes true after Index completes), and StoreQuorumRound re-stores it because the prior scheduling pass deleted it.
4. [nonvalidator/non\_validator.go:461–463](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L461-L463)
processReplicationState then deletes and schedules the sequence again: each replayed ReplicationResponse yields one additional duplicate task for the same sequence in the bounded channel.
5. [simplex/replication\_state.go:123–136](https://github.com/ava-labs/Simplex/blob/main/simplex/replication_state.go#L123-L136)
storeSequence's duplicate check (seqs[seq] exists) is defeated because DeleteSeq removed the entry when the first task was scheduled; re-admission succeeds on every replay.
6. [common/sched.go:69–83](https://github.com/ava-labs/Simplex/blob/main/common/sched.go#L69-L83)
Duplicate tasks occupy slots in the bounded (500-capacity) task channel that is shared by all finalized-block verifications of the nonvalidator, counting toward the ErrTooManyPendingVerifications admission limit.
7. [nonvalidator/non\_validator.go:244–261](https://github.com/ava-labs/Simplex/blob/main/nonvalidator/non_validator.go#L244-L261)
Each duplicate task calls block.Verify BEFORE the defensive nextSeqToCommit check. Duplicates run only after the head task completes (single scheduler goroutine): if the head verification succeeded, the OneTimeVerifier cache makes them cheap; if it failed, the failure is not cached, so each duplicate re-executes a full VM verification and fires an extra ResendFinalizationRequest, amplifying CPU and network work per replayed message in the failure case.

## Impact
Standalone impact is bounded: redundant VM verifications and extra replication requests (amplification), plus consumption of the bounded shared verification queue - degrading the nonvalidator's block-processing throughput (availability LOW). No integrity or confidentiality impact: duplicates cannot double-index (defensive nextSeqToCommit check) and all data remains QC-verified. The severe outcome (permanent halt) arises only in combination with the consume-before-schedule finding, rated separately.

## Reproduction steps
1. Any peer able to deliver Simplex app messages to the nonvalidator can replay the publicly available finalized quorum round for the current sequence; no validator identity or stake is required and no forged signatures are needed. Creating duplicates requires only that the first task is still queued or executing - trivially arranged by sending replays back-to-back while a VM verification is in flight - but it is still a runtime-state/timing condition, so attack requirements PRESENT.

## Recommended fix
The uniqueness check for verification tasks (IsSequenceScheduled) only observes tasks with unresolved dependencies, not tasks waiting in the ready channel or currently executing, so the 'schedule at most one task per sequence' invariant relied upon by scheduleNewFinalizedBlockTask is not enforced. Fix criteria: Sequence-task uniqueness must hold across the task's entire lifecycle (pending dependencies, ready queue, executing) until the sequence is indexed or the task definitively abandoned. Verify that replaying the same quorum round for nextSeqToCommit in multiple ReplicationResponse messages while its task is queued/executing results in no additional scheduled tasks and no growth of the scheduler queue.

---
**Severity:** MEDIUM
**Status:** Open
**Category:** Improper enforcement of unique action
**CWE:** [CWE-837](https://cwe.mitre.org/data/definitions/837.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 common/block_scheduler.go:124-135 and nonvalidator/non_validator.go:402-417, then trace scheduling through non_validator.go:461-508 and common/sched.go:69-83. Reproduce repeated ReplicationResponse messages while the task is queued or executing. Done means the same sequence remains unique across dependencies, the ready queue, and execution, with no additional scheduled tasks or queue growth.

Written by the indexing model from the issue text.

Assessment

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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.