Skip to content

Sync Loop NPT 25 26 - #956

Closed
aszepieniec wants to merge 2 commits into
masterfrom
security/sync-loop-npt-25-26
Closed

Sync Loop NPT 25 26#956
aszepieniec wants to merge 2 commits into
masterfrom
security/sync-loop-npt-25-26

Conversation

@aszepieniec

Copy link
Copy Markdown
Contributor

Harden the sync loop against DoS attacks.

aszepieniec and others added 2 commits July 30, 2026 23:02
The `BlockValidationError` arm of the successors-task result match logged
"Terminating sync loop" but did not break out of the event loop. Control
fell through to the respawn check, which found the offending block still
covered — it is only deleted after a successful send to the main loop —
and spawned a new successors subtask against the same tip. That subtask
re-read the same block, failed the same validation, and returned the same
error, forever.

Unlike the `Continue` path, the error path has no sleep, so this is a hot
loop that re-runs `Block::is_valid` — proof verification included — on a
pegged core while sync never completes. A peer can reach it: the sync
loop's `receive_block` performs no validation, and the peer loop only
runs `solo_validate` before forwarding, which deliberately leaves the
block's place in the chain unchecked. A solo-valid block from another
fork at a covered height is enough.

Every other arm of that match breaks. Add the missing break.

Test `invalid_tip_successor_terminates_sync_loop` asserts the sync loop
task ends within 10 seconds when a tip-successor fails validation; it
times out without this fix. The existing test-only `BlockValidator::Test`
accepts everything, so this adds a `TestReject` variant and a way to
inject it before the loop starts.

Addresses NPT-25 in R8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`RapidBlockDownload::receive_block` stores whatever block a peer offers
and binds the height to it. Nothing about the block's place in the chain
is checked: upstream, the peer loop runs only `solo_validate`, which by
its own documentation does not establish that the block belongs to any
chain -- difficulty and proof-of-work relative to the parent fall outside
it. A block mined on a private low-difficulty fork passes.

Worse, the binding could not be undone. `SynchronizationBitMask` had no
`unset`, and `canonize` folds a newly covered height below `lower_bound`,
where bits are implicitly one forever. The tip-successor slot is the one
that gets folded immediately. Since block requests are drawn from the
complement of that bit mask, a bound height is never asked for again: one
peer winning the race for one height denied the whole sync, repeatedly
and at little cost.

Make the binding revocable:

  - `SynchronizationBitMask::unset` walks `lower_bound` back down to the
    index, materializing the limbs `canonize` dropped on the way up. The
    limbs it reclaims lie entirely below the old bound, so they are all
    ones. A complete bit mask keeps no limbs at all, so the limb holding
    the bound is put back first, with only the bits below the bound set --
    the bits at and beyond it are the implicit zeros and `pop_count`
    reads them raw.
  - `RapidBlockDownload::reject_block` deletes the stored block and
    un-covers its height, putting it back on the to-do list.
  - `BlockValidationError` and `BlockPowError` now carry the offending
    height and the tip reached before the failure. The tip matters: the
    subtask may have processed blocks before hitting the bad one, and
    that progress used to be discarded.
  - The sync loop discards the block and asks for the height again rather
    than aborting, giving up only after `MAX_REJECTED_BLOCKS`. This
    replaces the unconditional break added for NPT-25; eviction subsumes
    it, and the retry bound still terminates the loop instead of spinning
    on a block that can never validate.

Test `poisoned_height_slot_does_not_prevent_sync` has a peer answer one
height with a block the validator rejects, withheld until every other
height is downloaded, and then behave honestly. Sync must still complete;
before this change it aborted with the tip never advancing.
`rejected_block_frees_its_height_slot` covers the eviction itself, and
three tests cover `unset`, including the complete-bit-mask case where no
limbs exist to walk back into.

Addresses NPT-26 in R8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aszepieniec
aszepieniec requested a review from Sword-Smith July 30, 2026 22:49
@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 6 untouched benchmarks


Comparing security/sync-loop-npt-25-26 (ba01795) with master (e9f8f9f)

Open in CodSpeed

Comment on lines +29 to +37
/// Rejects every block; for testing how the sync loop handles a
/// tip-successor that fails validation.
#[cfg(test)]
TestReject,
/// Rejects one specific block; for testing how the sync loop handles a
/// height slot poisoned with a block that does not belong to the chain
/// being synced.
#[cfg(test)]
TestRejectDigest(tasm_lib::prelude::Digest),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool!

@Sword-Smith

Copy link
Copy Markdown
Member

cargo fmt disagrees with some formatting. And I personally think the long comments before tests are annoying. If the test name itself can describe what's being tested, that's preferably. Otherwise, LGTM.

@Sword-Smith

Copy link
Copy Markdown
Member

cargo fmt disagrees with some formatting. And I personally think the long comments before tests are annoying. If the test name itself can describe what's being tested, that's preferably. Otherwise, LGTM.

I'll fix these two cosmetic problems.

@Sword-Smith

Copy link
Copy Markdown
Member

Merged through 2142d4d

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants