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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions