Skip to content

validate_block_drand skips per-epoch round checks for unchained beacons #7413

Description

@LesnyRumcajs

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, in validate_block_drand (#L99-L191), backed by verify_entries (src/beacon/drand.rs#L259-L324).

What Forest checks (unchained)

  1. the last entry's round equals max_beacon_round_for_epoch(epoch) (header.rs#L172), and
  2. every entry's BLS signature is valid for its own claimed round (verify_entries unchained path #L265-L285, via message_unchained(entry.round())).

What Lotus additionally checks

ValidateBlockValues (chain/beacon/beacon.go#L92-L100):

if !currBeacon.IsChained() {
    for i, e := range h.BeaconEntries {
        correctRound := currBeacon.MaxBeaconRoundForEpoch(nv, parentEpoch+abi.ChainEpoch(i)+1)
        if e.Round != correctRound {
            return xerrors.Errorf("unexpected beacon round %d, expected %d for epoch %d", ...)
        }
    }
}

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

Metadata

Metadata

Labels

Type: BugSomething isn't working

Type

No type

Projects

Status
Ready

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions