Skip to content

[Security][tentative] Medium (attackable): Block Response Delivery Blocks Resolver Actor Loop #346

Description

@evonide

Context

Summit's syncer uses Commonware's P2P resolver for missing block and certificate backfill. Resolver responses should be validated and handed to the syncer without letting one slow downstream block delivery stop the resolver from timing out other requests, processing cancellations, serving peers, or handling independent responses.

Claim

A malicious accepted P2P peer that is selected to answer an outstanding Summit Request::Block(commitment) fetch returns a valid block while the syncer mailbox is saturated, archive storage is slow, or the downstream report mailbox is backpressured; the Commonware resolver then awaits Summit's delivery path inline and stops servicing unrelated resolver events until the syncer sends the delivery acknowledgement. Commonware's resolver awaits consumer.deliver inline in its main actor loop, and Summit's consumer implementation waits for the bounded syncer mailbox plus a syncer actor response. For Request::Block responses, Summit sends that response only after decoding, digest checking, finalized archive writes, and the Update::Tip application report send, so one slow block delivery can park the whole resolver actor rather than just the affected fetch.

Flow

The path requires an outstanding block fetch to the peer and downstream syncer mailbox, storage, or reporter-mailbox delay at response time. This is narrower than arbitrary unsolicited traffic: Commonware first matches the response to an active (id, peer) request before calling Summit's consumer. Notarized and finalized response variants acknowledge before their later finalizer-facing processing, so the currently source-backed blocking claim is the Request::Block delivery path.

Impact

While parked in block delivery, the resolver actor cannot process active-request timeouts, pending retries, mailbox cancellations/retains, incoming peer requests, completed serve operations, or other network responses. A targeted node can therefore lose resolver liveness exactly when it is trying to recover missing finalized data, amplifying syncer storage or application-report backpressure into stalled backfill and peer serving.

Root Cause

The resolver response handoff is synchronous with the resolver actor loop, and Summit's block-delivery acknowledgement depends on downstream syncer archive and application-report progress instead of an isolated, cancellable, or capacity-limited validation worker.

Code

  • Summit locks commonware-resolver = "2026.4.0":
    commonware-resolver = "2026.4.0"
    .
  • Summit's resolver consumer sends a Deliver message to the syncer mailbox and then awaits the syncer actor's oneshot response:
    async fn deliver(&mut self, key: Self::Key, value: Self::Value) -> bool {
    .
  • The syncer processes resolver messages inside its main actor loop and drains each batch only after that arm is selected:
    // Handle resolver messages last (batched up to max_repair, sync once)
    .
  • Request::Block delivery sends the resolver acknowledgement only after decoding, digest equality, cached-finalization lookup, and store_finalization:

    summit/syncer/src/actor.rs

    Lines 889 to 910 in ed2c5c8

    Request::Block(commitment) => {
    let Ok(block) = B::decode_cfg(value.as_ref(), &self.block_codec_config) else {
    response.send_lossy(false);
    return false;
    };
    if block.digest() != commitment {
    response.send_lossy(false);
    return false;
    }
    // Persist the block, also storing the finalization if we have it.
    let height = block.height();
    let finalization = self.cache.get_finalization_for(commitment).await;
    let wrote = self
    .store_finalization(
    height,
    commitment,
    block,
    finalization,
    application,
    buffer,
    )
    .
  • store_finalization writes finalized block/finalization archives and awaits an application Update::Tip report before returning to the block response handler:

    summit/syncer/src/actor.rs

    Lines 1337 to 1404 in ed2c5c8

    async fn store_finalization<K: PublicKey>(
    &mut self,
    height: Height,
    commitment: B::Digest,
    block: B,
    finalization: Option<Finalization<P::Scheme, B::Digest>>,
    application: &mut impl Reporter<Activity = Update<B, P::Scheme, A>>,
    _buffer: &mut buffered::Mailbox<K, B>,
    ) -> bool {
    // Blocks below the last processed height are stale
    if height <= self.last_processed_height {
    debug!(
    %height,
    floor = %self.last_processed_height,
    ?commitment,
    "dropping finalization at or below processed height floor"
    );
    return false;
    }
    self.notify_subscribers(commitment, &block).await;
    #[cfg(feature = "prom")]
    let store_start = Instant::now();
    // In parallel, update the finalized blocks and finalizations archives
    if let Err(e) = try_join!(
    // Update the finalized blocks archive
    async {
    self.finalized_blocks.put(block).await.map_err(Box::new)?;
    Ok::<_, BoxedError>(())
    },
    // Update the finalizations archive (if provided)
    async {
    if let Some(finalization) = finalization {
    self.finalizations_by_height
    .put(height, commitment, finalization)
    .await
    .map_err(Box::new)?;
    }
    Ok::<_, BoxedError>(())
    }
    ) {
    panic!("failed to finalize: {e}");
    }
    #[cfg(feature = "prom")]
    {
    let store_duration = store_start.elapsed().as_micros() as f64;
    histogram!("syncer_block_store_duration_micros").record(store_duration);
    counter!("syncer_blocks_stored_total").increment(1);
    }
    // Update metrics and send tip update to application
    if height > self.tip {
    let gap = height.get() - self.tip.get();
    if gap > 1 {
    debug!(
    previous_tip = %self.tip,
    new_tip = %height,
    gap,
    "tip advanced by multiple blocks (catch-up)"
    );
    }
    application
    .report(Update::Tip(height.get(), commitment))
    .await;
    self.tip = height;
    .
  • In the production wiring, the syncer's reporter is the finalizer mailbox; report(Update::Tip) awaits bounded mailbox admission, while the finalizer later ignores the tip update, so the source-backed downstream wait is mailbox capacity rather than finalizer processing:

    summit/node/src/engine.rs

    Lines 455 to 459 in ed2c5c8

    let syncer_handle = self.syncer.start(
    self.finalizer_mailbox.clone(),
    self.buffer_mailbox.clone(),
    (resolver_rx, resolver),
    self.sync_start,
    ,
    impl<S: Scheme<B::Digest>, B: ConsensusBlock> Reporter for FinalizerMailbox<S, B> {
    type Activity = Update<B, S>;
    async fn report(&mut self, activity: Self::Activity) {
    self.sender
    .send(FinalizerMessage::SyncerUpdate { update: activity })
    .await
    .expect("Unable to send syncer update to Finalizer");
    , and
    FinalizerMessage::SyncerUpdate { update } => {
    match update {
    Update::Tip(_height, _digest) => {
    // I don't think we need this
    }
    .
  • Notarized and finalized response variants send their resolver acknowledgement before later store_finalization or Update::NotarizedBlock reporting, so this finding does not claim those later awaits keep Commonware's resolver loop parked:

    summit/syncer/src/actor.rs

    Lines 1088 to 1138 in ed2c5c8

    let commitment = block.digest();
    debug!(?round, %height, "received finalization");
    wrote |= self
    .store_finalization(
    height,
    commitment,
    block,
    Some(finalization),
    application,
    buffer,
    )
    .await;
    }
    PendingVerification::Notarized {
    notarization,
    block,
    response,
    } => {
    response.send_lossy(true);
    let round = notarization.round();
    let commitment = block.digest();
    debug!(?round, ?commitment, "received notarization");
    // If there exists a finalization certificate for this block, we
    // should finalize it.
    let height = block.height();
    if let Some(finalization) = self.cache.get_finalization_for(commitment).await {
    wrote |= self
    .store_finalization(
    height,
    commitment,
    block.clone(),
    Some(finalization),
    application,
    buffer,
    )
    .await;
    }
    // Cache the notarization and block.
    self.cache_block(round, commitment, block.clone()).await;
    self.cache
    .put_notarization(round, commitment, notarization)
    .await;
    application
    .report(Update::NotarizedBlock(block.clone()))
    .await;
    self.notify_subscribers(commitment, &block).await;
    }
    }
    .

Related Issues/PRs

The following existing items touch the same trust boundary, adjacent root cause, or likely remediation stack.

Note that #346 is inline delivery blocking resolver progress.

Fix

Do not await Summit block delivery directly in the resolver actor loop. Hand matched responses to a bounded delivery worker pool with per-peer/key limits, make syncer delivery cancellation-aware, and add a regression test proving one blocked block delivery does not stop resolver timeouts, cancellations, serve requests, or unrelated fetch responses.

Metadata

Metadata

Type

No type

Fields

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