Epoch discovery accepts a sealing block on a head count, letting low-stake validators forge epochs
メンテナーはふだん 1 日以内に返信
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 35/100
- issue の種類
- バグ
- 明瞭さ
- 明確に書かれている
- 活発さ
- 活発
- 技術スタック
- go
調査の方向性
Start with nonvalidator/epochs.go, especially maybeObserveThresholdResponses, then trace the unknown epoch and sealing block paths in nonvalidator/non_validator.go and nonvalidator/future_epoch_listener.go. Review the existing POS tests in simplex/pos_test.go and add coverage for both weighted reporter thresholds and rejecting blocks whose epoch does not match the validated sealing block chain. Done means low stake identities cannot install an epoch or trigger the listener, and an unrelated higher epoch cannot replace a genuine stored block.
索引モデルが issue の本文から書いたものです。
説明
Details
A group of validators holding a small share of the stake can make a catching-up node adopt a validator set they invented. The node then stores blocks signed only by that group. A running validator can be stopped and restarted with the attacker's set. This applies only to stake-weighted deployments. The cause is maybeObserveThresholdResponses, which counts identities and ignores Node.Weight.
flowchart TD
n1["attacker with F(n)+1 low-stake validator identities"]
n2["maybeObserveThresholdResponses"]
n3["maybeOpenEpochFromSealingBlock"]
n4["processQuorumRound and storeQuorumRound"]
n5["victim node's stored chain and genuine epochs"]
n6["running validator via FutureEpochListener"]
n7["stake-weighted threshold"]
n8["check tying a block's epoch to its sequence"]
n1 -->|"sends made-up sealing block"| n2
n2 -->|"head count reaches threshold"| n3
n3 -->|"fake epoch installed"| n4
n1 -->|"sends blocks signed by own keys"| n4
n4 -->|"replaces genuine entries and indexes"| n5
n2 -->|"same count stops and restarts it"| n6
n7 -->|"not applied"| n2
n8 -->|"not applied"| n4
classDef actor stroke:#2563eb,stroke-width:2px
classDef vulnerable_code stroke:#dc2626,stroke-width:2px
classDef existing_control stroke:#16a34a,stroke-width:2px
classDef missing_control stroke:#dc2626,stroke-width:2px,stroke-dasharray:5 5
classDef asset stroke:#9333ea,stroke-width:2px
classDef component stroke:#6b7280,stroke-width:1px
class n1 actor
class n2 vulnerable_code
class n3 component
class n4 vulnerable_code
class n5 asset
class n6 asset
class n7 missing_control
class n8 missing_control
- attacker registers
F(n)+1validator identities holding under one third of the stake. This stays within the fault bound a stake-weighted L1 tolerates, but is enough identities to meet a count-based threshold. - attacker identities each send a
ReplicationResponsewith the same made-up sealing block. The block hasSeqandEpochset to X, above the victim's tip, and names the attacker's own validator set and keys. (nonvalidator/epochs.go:178-216) maybeObserveThresholdResponsesaccepts the sealing block once the response count reachescommon.F(len(validators)) + 1. It counts one response per node ID and never readsNode.Weight, so no honest reporter is guaranteed. (nonvalidator/epochs.go:178-216)maybeOpenEpochFromSealingBlockinstalls epoch X using the validator set taken from the block'sSealingBlockInfo().ValidatorSet. The finalization attached to the sealing block is never checked, and the signer set and signature aggregator are built from the attacker's keys. (nonvalidator/non_validator.go:424-455)- attacker sends blocks for the victim's next sequences that name epoch X, with finalizations signed by its own keys.
processQuorumRoundchecks each finalization against whichever epoch the block names, so the checks pass. (nonvalidator/non_validator.go:697-781) storeQuorumRoundlets the higher-epoch attacker entry replace the genuine stored entry. The genuine entry is treated as a Telock, because nothing ties a block's epoch to its sequence. (nonvalidator/non_validator.go:697-781)- the indexing task indexes the attacker's block and deletes every lower, genuine epoch. The non-validator runs only VM verification, which accepts a nil inner block, and
removeOldSequencesAndEpochs(md.Seq, md.Epoch)then removes the genuine epochs. (nonvalidator/non_validator.go:365-418)
Evidence
- nonvalidator/epochs.go:178–216
- nonvalidator/non_validator.go:209–241
- nonvalidator/non_validator.go:783–805
- nonvalidator/non_validator.go:424–455
- nonvalidator/epochs.go:30–51
- nonvalidator/non_validator.go:697–781
- nonvalidator/non_validator.go:659–692
- nonvalidator/non_validator.go:365–418
- external.go:62–75
- common/api.go:179–182
- README.md:32–38
- simplex/pos_test.go:27–37
- nonvalidator/future_epoch_listener.go:42–98
- instance.go:500–549
- instance.go:212–264
Impact
A non-validator indexes blocks the attacker wrote, loses its genuine epochs and follows only the attacker's fork. Its stored chain and the state it serves are attacker-controlled, and it stops following the real chain until an operator repairs it. A running validator is pushed out of real consensus, and doing this to enough validators halts the network. No secret data is exposed.
Reproduction steps
- The attacker must control
F(n)+1validator identities on the current P-chain list, wherenis the number of identities. Together they hold under one third of the stake, on an L1 whose quorums are weighted by stake. A deployment that measures faults purely by head count is not affected, and the report notes the threat model does not say whether "<1/3 Byzantine validators" means count or stake. Two kinds of victim are reachable. A bootstrapping non-validator reaches the flaw throughprocessSealingBlock. An already-synced non-validator reaches it throughhandleQrFromUnknownEpoch, which uses the same count threshold for a block naming an unknown epoch. A running validator reaches it throughFutureEpochListener, which feeds every replication response to the same count-based collector. When the threshold is met,onHigherSealingBlockandprocessEpochChangestop the validator and restart it with the attacker's set or as a non-validator.
Recommended fix
(1) The threshold in maybeObserveThresholdResponses is a count of node IDs over the latest P-chain list. On a stake-weighted L1, a low-stake group with many identities can meet it with no honest reporter.
Fix criteria: A sealing block counts as authentic only when its reporters, weighted the same way quorums are (stake from Node.Weight or the host's aggregator), exceed the tolerated Byzantine weight. A test shows that F(n)+1 reporters holding less than one third of the weight neither add an epoch to n.epochs nor trigger FutureEpochListener.
(2) Once an epoch is in n.epochs, a block at any sequence can name it, and a higher-epoch entry displaces a genuine one. Nothing ties a block's epoch to the sealing-block chain below its sequence.
Fix criteria: A block at sequence S is verified only against the epoch whose sealing block is the latest one below S on the validated chain. A test shows that a block at the next sequence naming a higher, unrelated epoch is rejected and does not replace a genuine stored entry.
Status on main
Re-checked against main at 90995e0 on 2026-10-26; still present:
maybeObserveThresholdResponses (nonvalidator/epochs.go:173-216) still uses threshold := common.F(len(validators)) + 1 and counts responses per node ID, ignoring Node.Weight. Open issue #615 ('Count threshold voting by stake weight if using POS') tracks part (1). Part (2) also remains: nothing ties a block's epoch to the sealing-block chain below its sequence, and a higher-epoch entry replaces a stored one (nonvalidator/non_validator.go:766-775).
Related: #615
Severity: MEDIUM
Status: Open
Category: Insufficient verification of data authenticity
CWE: CWE-345
Repository: ava-labs/Simplex
Branch: main
Date created: 2026-10-26
Claude Security finding: https://claude.ai/security/findings/find_01CCrWwtgL6tAyAMvgs6HCmp
- 主要言語
- Go
- スター
- 22
- フォーク
- 4
- 平均マージ
- 1日 16時間
- マージ済み PR(30日)
- 35
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
ava-labs/Simplex のほかの issue
-
Unverified sealing-block finalization lets a validator redirect a syncing node's replication requests対応中かも @yacovm が今日担当しました。 オープンlow security
難易度 4/5 3〜5日 初心者へのやさしさ 45/100
ava-labs/Simplex#666 · 担当者 1 名 ·
メンテナーはふだん 1 日以内に返信
-
Failed notarization WAL write lets a node sign both a finalize vote and an empty vote対応中かも @yacovm が 3 日前に担当しました。 オープンmedium security
難易度 4/5 3〜5日 初心者へのやさしさ 38/100
メンテナーはふだん 1 日以内に返信
-
Builder ignores parent's P-chain height, so a leader's maximal height stalls honest proposals対応中かも @yacovm が 3 日前に担当しました。 オープンmedium security
難易度 4/5 3〜5日 初心者へのやさしさ 55/100
メンテナーはふだん 1 日以内に返信
-
Honest builders never raise timestamp to parent's, so a leader's future timestamp voids honest proposals対応中かも @yacovm が 3 日前に担当しました。 オープンhigh security
難易度 4/5 3〜5日 初心者へのやさしさ 43/100
メンテナーはふだん 1 日以内に返信
-
Auxiliary-info store keeps unbounded data from any unauthenticated peer対応中かも @yacovm が 3 日前に担当しました。 オープンhigh security
難易度 4/5 3〜5日 初心者へのやさしさ 56/100
ava-labs/Simplex#652 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
ava-labs/Simplex の issue をすべて見る
似ている issue
-
docs(prompts): IF BLOCKED workflow-scope branch says to comment on the issue on jira/local trackersオープンagent-butler-finding bug
難易度 2/5 1〜3時間 初心者へのやさしさ 77/100
jordansmall/spindrift#6238 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 85/100
microcks/microcks-cli#608 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
bug from-studio
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
esengine/DeepSeek-Reasonix#14868 ·
メンテナーはふだん 1 日以内に返信
-
Install notes print "Camunda REST API: https://" without a host when global.host is set対応中かも @irisfield が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
camunda/camunda-platform-helm#7790 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
wso2/agent-manager#2220 ·
メンテナーはふだん 1 日以内に返信