You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Entry i must be at exactly the round for epoch parentEpoch+i+1. Combined with the last-round check, this pins both the count (= epoch − parentEpoch) and the exact round of every entry.
The gap
Forest enforces only the last entry's round plus per-entry signature validity — not that intermediate entries sit at the correct per-epoch rounds, nor that the count matches the covered-epoch span. Since drand signatures for all rounds are public, wrong-but-real entries (each individually BLS-valid, last one at max_round) pass Forest and fail Lotus.
The rest of Forest's block validation does not compensate: election proof, ticket, and winning-PoSt validation all draw randomness from header.beacon_entries.last() only (ticket #L313, winning PoSt #L350). Nothing iterates the list or constrains its count/positions. So validate_block_drand is the sole structural constraint, and the divergence is reachable at the validation layer. It is one-directional: Forest's checks are a strict subset of Lotus's, so Forest never rejects a Lotus-valid block on these grounds — it only accepts extra ones.
Reachability
Two triggers produce a block Forest accepts and mainnet rejects:
Adversarial (Bug 2 alone): a miner with block-producing power wins a round legitimately (so election/ticket/PoSt validate off the correct .last() entry), then pads the header with extra valid-but-wrong-round entries. Forest accepts; Lotus rejects.
Honest (Bug 1 ∘ Bug 2): a miner using Forest's MinerGetBaseInfo after a null round gets a one-entry base (Bug 1) where the network requires one per covered epoch. Forest validates it (last entry is max_round(E), signature valid); Lotus rejects it (positional loop expects max_round(E−1) at index 0). No malice required.
Exploitability / severity
Low in Forest's current reality:
Forest is not a block producer, so the honest trigger only fires if someone mines through Forest on mainnet — effectively nobody today. Latent, not live.
The adversarial path is economically irrational. It needs real mainnet storage power, and a miner produces one block per election win — publishing the malformed block means forfeiting the block reward of the valid one you could have produced instead. Cost per attempt is a lost block reward; direct gain is zero (no theft, no double-spend).
Impact is transient and Forest-only. It affects only Forest nodes (a network minority), and the malformed block's fork gets no follow-on weight, so Forest reorgs onto the heavier mainnet chain and drops it. Concrete damage: Forest serves a divergent tipset (wrong messages/receipts/state over RPC) for the affected height, then self-corrects.
Not embargo-worthy. Two things would raise it: (1) Forest becoming a mining backend, at which point Bug 1 ∘ Bug 2 is a free, recurring, honest self-split after every null round; (2) F3 finalizing Forest's divergent view before the reorg — unverified here, worth a check by whoever takes the fix, since it could turn "transient" into "sticky".
Fix
In the unchained validation path, add Lotus's positional loop — assert beacon_entries.len() == epoch − parent_epoch and that entry i's round equals max_beacon_round_for_epoch(parent_epoch + i + 1). This is the same per-epoch-round rule as Bug 1, applied on the validation side rather than the production side — fix both together behind a shared helper. Compare against Lotus's read-side equivalent, getBeaconEntryV3 (chain/rand/rand.go#L146).
Note
This was AI found and seems correct, but do your own checks. Regardless of that, a test will be useful.
Tip
It might be best to fix this together with #7412, as those are pretty much related and address the same class of bug.
Location:
src/blocks/header.rs#L172-L188, invalidate_block_drand(#L99-L191), backed byverify_entries(src/beacon/drand.rs#L259-L324).What Forest checks (unchained)
max_beacon_round_for_epoch(epoch)(header.rs#L172), andverify_entriesunchained path#L265-L285, viamessage_unchained(entry.round())).What Lotus additionally checks
ValidateBlockValues(chain/beacon/beacon.go#L92-L100):Entry
imust be at exactly the round for epochparentEpoch+i+1. Combined with the last-round check, this pins both the count (= epoch − parentEpoch) and the exact round of every entry.The gap
Forest enforces only the last entry's round plus per-entry signature validity — not that intermediate entries sit at the correct per-epoch rounds, nor that the count matches the covered-epoch span. Since drand signatures for all rounds are public, wrong-but-real entries (each individually BLS-valid, last one at
max_round) pass Forest and fail Lotus.The rest of Forest's block validation does not compensate: election proof, ticket, and winning-PoSt validation all draw randomness from
header.beacon_entries.last()only (ticket#L313, winning PoSt#L350). Nothing iterates the list or constrains its count/positions. Sovalidate_block_drandis the sole structural constraint, and the divergence is reachable at the validation layer. It is one-directional: Forest's checks are a strict subset of Lotus's, so Forest never rejects a Lotus-valid block on these grounds — it only accepts extra ones.Reachability
Two triggers produce a block Forest accepts and mainnet rejects:
.last()entry), then pads the header with extra valid-but-wrong-round entries. Forest accepts; Lotus rejects.MinerGetBaseInfoafter a null round gets a one-entry base (Bug 1) where the network requires one per covered epoch. Forest validates it (last entry ismax_round(E), signature valid); Lotus rejects it (positional loop expectsmax_round(E−1)at index 0). No malice required.Exploitability / severity
Low in Forest's current reality:
Not embargo-worthy. Two things would raise it: (1) Forest becoming a mining backend, at which point Bug 1 ∘ Bug 2 is a free, recurring, honest self-split after every null round; (2) F3 finalizing Forest's divergent view before the reorg — unverified here, worth a check by whoever takes the fix, since it could turn "transient" into "sticky".
Fix
In the unchained validation path, add Lotus's positional loop — assert
beacon_entries.len() == epoch − parent_epochand that entryi's round equalsmax_beacon_round_for_epoch(parent_epoch + i + 1). This is the same per-epoch-round rule as Bug 1, applied on the validation side rather than the production side — fix both together behind a shared helper. Compare against Lotus's read-side equivalent,getBeaconEntryV3(chain/rand/rand.go#L146).