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
- Steady state:
LastStoredRound = 1000.
- As a validator, commit CR-v3 weights for
reveal_round = 1001 (the freshness gate passes).
- Wait for drand to advance to round 2000.
- 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.
- Confirm the genuine round-1001 pulse is now un-storable (
InvalidRoundNumber in dispatch, Stale at mempool).
- 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
Describe the bug
pallet_drand::write_pulseis unsigned and feeless; its only integrity gate is the BLS verifier inside the dispatch. An attacker can advanceLastStoredRoundpast 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:
(
lib.rs:344,:360,:381.) The attacker fetches the genuine drand pulse for the current real roundRand submits one barewrite_pulse. The pulse passes BLS,R > Lholds, andLastStoredRoundjumps fromLtoR. The honest offchain worker only fetches forward fromLastStoredRound + 1, so roundsL+1 .. R-1are never fetched. They can never be inserted later: the strict>check rejects them in dispatch (InvalidRoundNumber) and the mempool path drops them asStale. There is no admin or migration path to insert a historical round.The CR-v3 commit freshness gate only requires
reveal_round >= LastStoredRoundwith no upper bound (extensions/subtensor.rs:331), so a legitimately committedreveal_roundlands inside the gap. At reveal timePulses::get(reveal_round)returnsNone; 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-carryingwrite_pulseispropagate(false), so the attacker submits directly to a validator's RPC and wins an in-block race against the offchain worker.To Reproduce
LastStoredRound = 1000.reveal_round = 1001(the freshness gate passes).write_pulsecarrying the genuine round-2000 pulse to a validator's RPC;LastStoredRoundjumps to 2000, skipping 1001.InvalidRoundNumberin dispatch,Staleat mempool).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_pulsecannot 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 inwrite_pulse, and/or make the reveal sink resilient to a transiently missing round (see H-04).Screenshots
No response
Environment
testnet@0c774f00Additional context
No response