VerifyBlockMessageVote skips digest and signature checks without binding to the verified vote
- Dominant language
- Go
- Stars
- 22
- Forks
- 4
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 34
Description
## Details
VerifyBlockMessageVote (simplex/epoch.go:2347) is a verification-skip cache: if futureMessages[from][round].proposal exists and its block header equals the incoming message's header, the function returns nil immediately, before the two security checks it exists to perform: (1) that vote.Vote.Digest matches the block digest and (2) that the vote signature verifies against the proposer's public key. The cached entry attests only that SOME vote for this (node, round, header) was verified in the past; it says nothing about the vote attached to the CURRENT message, which is then propagated into createBlockVerificationTask and, if that task creates the Round, stored as the leader's vote in round.votes (epoch.go:2188) without ever being verified.
Because handleBlockMessage reassigns from = vote.Signature.Signer (epoch.go:1854), any validator — not just the leader — can send the leader's block bytes with a forged vote {Signer: leader, Value: garbage, Vote: arbitrary header}, and the skip fires as long as the genuine proposal is still buffered for the now-current round (the buffered proposal is only deleted inside the asynchronous verification task, epoch.go:2154).
Exploitation is interleaving-dependent: in the common path the genuine buffered proposal is reprocessed first and its FIFO-scheduled task creates the Round, so the forged task's storeProposal fails and the forged vote is discarded. The unverified vote is stored when the genuine proposal's processing did not schedule a task (parent block not yet locatable so verifyProposalMetadataAndBlacklist returned false, or the verification queue was full so ScheduleTaskWithDependencies errored) and the attacker's forged message arrives before the genuine proposal is reprocessed. In that case round.votes[leader] holds a never-verified vote whose garbage signature is aggregated into the notarization QC (same downstream impact as the vote-overwrite finding: fabricated quorum member, invalid QC persisted to WAL and broadcast).
## Evidence
1. [simplex/epoch.go:2344–2377](https://github.com/ava-labs/Simplex/blob/main/simplex/epoch.go#L2344-L2377)
VerifyBlockMessageVote returns nil (skipping BOTH the vote-digest-matches-block check at 2365 and the signature verification at 2371) whenever a proposal from the same node for the same round with an equal block header is buffered in futureMessages. The skip only compares block headers; it never compares the NEW message's vote to the vote that was actually verified when the buffered proposal was stored. The incoming message's vote (arbitrary header, arbitrary signature bytes) is therefore treated as verified.
2. [simplex/epoch.go:1853–1854](https://github.com/ava-labs/Simplex/blob/main/simplex/epoch.go#L1853-L1854)
handleBlockMessage reassigns from = vote.Signature.Signer, so ANY validator (the transport-level sender only has to be some validator) can submit a BlockMessage carrying the leader's block together with a forged vote struct claiming the leader as signer. Combined with the skip above, the leader-authenticity of the vote is never established for this message.
3. [simplex/epoch.go:2180–2195](https://github.com/ava-labs/Simplex/blob/main/simplex/epoch.go#L2180-L2195)
The block-verification task created from the (unverified) message stores the attacker-supplied vote into round.votes for the leader (line 2188) if this task is the one that creates the Round. From there the unverified vote is counted by maybeCollectNotarization and its garbage signature aggregated into the notarization QC, exactly as in the vote-overwrite finding.
4. [simplex/epoch.go:1905–1942](https://github.com/ava-labs/Simplex/blob/main/simplex/epoch.go#L1905-L1942)
For a current-round message the code proceeds past the skip to verifyProposalMetadataAndBlacklist and schedules a verification task carrying the forged vote. If the earlier processing of the genuine buffered proposal did not result in a scheduled task (e.g., metadata verification failed because the parent block was not yet locatable, or ScheduleTaskWithDependencies returned ErrTooManyPendingVerifications), the forged message's task is the one that creates the round and stores the forged vote.
## Impact
When the interleaving succeeds, the victim counts a never-verified vote attributed to the leader toward notarization quorum and aggregates a garbage signature into its QC: integrity of the node's consensus state is compromised (notarization without valid quorum persisted, round advanced, finalize vote emitted). Availability impact is limited desync recoverable via replication. Gated on the runtime precondition, rated integrity HIGH / availability LOW.
## Reproduction steps
1. Attacker is any single validator. It records the leader's broadcast proposal for a future round r (buffered by the victim), then when the victim enters round r sends a BlockMessage with the same block and a forged leader vote. The skip suppresses digest and signature verification. For the forged vote to actually be stored, the genuine proposal's first processing must have failed to schedule a task (missing parent at that moment, or verification queue exhaustion) — a runtime state outside the attacker's direct control, hence attack_requirements PRESENT.
## Recommended fix
The verification-skip in VerifyBlockMessageVote keys on (sender, round, block header) but does not bind the skip to the vote object that was actually verified, allowing a different, unverified vote to be treated as verified; the from = vote.Signature.Signer reassignment additionally lets any validator submit messages on the leader's behalf. Fix criteria: The skip must only apply when the incoming message's vote is identical to the previously verified vote (same signer, same signed bytes, same signature); otherwise the digest-match and signature checks must run. After the fix, a BlockMessage carrying the leader's block with a forged vote must be rejected regardless of buffered proposals, and no unverified vote can reach round.votes via createBlockVerificationTask.
---
**Severity:** MEDIUM
**Status:** Open
**Category:** Improper signature verification
**CWE:** [CWE-347](https://cwe.mitre.org/data/definitions/347.html)
**Repository:** ava-labs/Simplex
**Branch:** main
**Date created:** 2026-08-21
---
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in simplex/epoch.go at VerifyBlockMessageVote (2347), then trace handleBlockMessage (1853), the current-round path (1905–1942), and createBlockVerificationTask (2180–2195). Confirm the cache binds a skip to the previously verified vote, while forged or mismatched votes still undergo digest and signature checks; done means the forged leader vote is rejected and no unverified vote reaches round.votes.
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
- 48/100