Skip to content

Any account can jump pallet-drand LastStoredRound to skip rounds, permanently denying timelock reveals #2794

Description

@Maksandre

Describe the bug

pallet_drand::write_pulse is unsigned and feeless; its only integrity gate is the BLS verifier inside the dispatch. An attacker can advance LastStoredRound past intermediate rounds that are then never stored and never backfillable.

The dispatch stores a pulse only if its round is strictly greater than the last stored round, then advances the pointer:

ensure_none(origin)?;
// ...
@>  ensure!(pulse.round > last_stored_round, Error::<T>::InvalidRoundNumber);
    Pulses::<T>::insert(pulse.round, pulse.clone());
    last_stored_round = pulse.round;
// ...
LastStoredRound::<T>::put(last_stored_round);

(lib.rs:344, :360, :381.) The attacker fetches the genuine drand pulse for the current real round R and submits one bare write_pulse. The pulse passes BLS, R > L holds, and LastStoredRound jumps from L to R. The honest offchain worker only fetches forward from LastStoredRound + 1, so rounds L+1 .. R-1 are never fetched. They can never be inserted later: the strict > check rejects them in dispatch (InvalidRoundNumber) and the mempool path drops them as Stale. There is no admin or migration path to insert a historical round.

The CR-v3 commit freshness gate only requires reveal_round >= LastStoredRound with no upper bound (extensions/subtensor.rs:331), so a legitimately committed reveal_round lands inside the gap. At reveal time Pulses::get(reveal_round) returns None; the commit is re-queued for one epoch then deleted (see H-04), and the committed weights are silently dropped. Metadata timelock commitments hit the same lookup and stay stuck with their deposit locked. The round-carrying write_pulse is propagate(false), so the attacker submits directly to a validator's RPC and wins an in-block race against the offchain worker.

To Reproduce

  1. Steady state: LastStoredRound = 1000.
  2. As a validator, commit CR-v3 weights for reveal_round = 1001 (the freshness gate passes).
  3. Wait for drand to advance to round 2000.
  4. As any account, submit one bare write_pulse carrying the genuine round-2000 pulse to a validator's RPC; LastStoredRound jumps to 2000, skipping 1001.
  5. Confirm the genuine round-1001 pulse is now un-storable (InvalidRoundNumber in dispatch, Stale at mempool).
  6. At the reveal epoch, observe Pulses::get(1001) == None, the commit re-queued and then deleted on the next epoch step; the weights are never applied, with no event.

Expected behavior

A single write_pulse cannot skip past rounds that have not yet been stored, or the reveal path tolerates a transiently missing round long enough for it to be stored. Bound the per-call round skip in write_pulse, and/or make the reveal sink resilient to a transiently missing round (see H-04).

Screenshots

No response

Environment

testnet@0c774f00

Additional context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions