Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments
Network not being able to confirm new transactions (total network shutdown)
Description
Brief/Intro
EngineActor runs every node request through one tokio::select! loop. When the downstream RPC channel fills up, the active arm blocks on rpc_tx.send().await, and select! can't move to another branch until that await returns. An unauthenticated optimism_outputAtBlock flood on the public rollup-node RPC fills the channel, blocks the actor, and starves every build, seal, and block-processing request queued behind it. The sequencer stops confirming L2 transactions until the flood stops.
Vulnerability Details
The loop in crates/consensus/service/src/actors/engine/actor.rs:
rpc_tx has capacity 1024. The EngineRpcProcessor that drains it holds 16 concurrent permits, and its drain loop calls recv()beforeacquire_owned().await, so when permits are all in use, items stop coming off the channel. EngineQueries::OutputAtBlock runs l2_block_by_label against the EL (a DB lookup that's slow for cold/archived blocks); pre-Isthmus it additionally runs eth_getProof. Either path is enough to saturate the 16-permit semaphore under sustained load. optimism_outputAtBlock is served by RollupRpc with no auth; l2_jwt_secret only covers the engine RPC (op-node ↔ EL).
Failure sequence:
Attacker spams cold-block optimism_outputAtBlock faster than the drain rate (a few hundred req/s).
The 16 permits fill with slow eth_getProof. rpc_tx reaches capacity.
The actor pulls the next RPC request, hits rpc_tx.send().await, blocks.
select! can't re-enter while that arm is awaiting. Non-RPC requests queued behind it are starved even though their downstream channel is empty.
Impact Details
"Network not being able to confirm new transactions (total network shutdown)"
With Base's single sequencer, blocking the actor halts block production while the flood is active.
"Temporary freezing of network transactions by delaying one block by 500% or more"
Base targets 2-second blocks. Even 10 seconds of sustained traffic trips the 500% threshold.
Attack profile:
Unauthenticated. Reachable from any host that can hit the rollup-node JSON-RPC.
A few hundred cold-block queries per second is enough.
Liveness recovers within seconds once the flood stops.
Funds aren't at risk; L1 force-include remains available.
crates/consensus/engine/src/query.rs — slow EL calls behind OutputAtBlock
crates/consensus/rpc/src/rollup.rs — public unauthenticated RPC entrypoint
Proof of Concept
Make these changes
rpc_backpressure_stalls_engine_processing_routing: real EngineActor with a stalled mock RPC receiver. Once rpc_tx is full and the actor is blocked, a ProcessFinalizedL2BlockNumberRequest sent on the inbound channel never reaches the engine processor, which is idle with an empty queue.
engine_processing_routes_when_rpc_drains: same harness, draining receiver. The same processing request routes through. One variable changed, opposite results.
diff --git a/crates/consensus/service/src/actors/engine/actor.rs b/crates/consensus/service/src/actors/engine/actor.rs
index 1a3f1ee24..adc253be1 100644
--- a/crates/consensus/service/src/actors/engine/actor.rs
+++ b/crates/consensus/service/src/actors/engine/actor.rs
@@ -168,3 +168,145 @@ where
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use std::{
+ sync::{
+ Arc,
+ atomic::{AtomicUsize, Ordering},
+ },
+ time::Duration,
+ };
+
+ use base_consensus_engine::EngineQueries;
+ use tokio::{
+ sync::oneshot,
+ task::JoinHandle,
+ time::{sleep, timeout},
+ };
+
+ use super::*;
+ use crate::{EngineProcessingRequest, EngineRpcRequest};
+
+ struct StalledRpcReceiver;
+
+ impl EngineRpcRequestReceiver for StalledRpcReceiver {
+ fn start(
+ self,
+ request_channel: mpsc::Receiver<EngineRpcRequest>,
+ ) -> JoinHandle<Result<(), EngineError>> {
+ tokio::spawn(async move {
+ let _kept = request_channel;
+ std::future::pending::<()>().await;
+ Ok(())
+ })
+ }
+ }
+
+ struct CountingProcessor(Arc<AtomicUsize>);
+
+ impl EngineRequestReceiver for CountingProcessor {
+ fn start(
+ self,
+ mut request_channel: mpsc::Receiver<EngineProcessingRequest>,
+ ) -> JoinHandle<Result<(), EngineError>> {
+ let counter = self.0;
+ tokio::spawn(async move {
+ while request_channel.recv().await.is_some() {
+ counter.fetch_add(1, Ordering::SeqCst);
+ }
+ Ok(())
+ })
+ }
+ }
+
+ fn rpc_request() -> EngineActorRequest {
+ let (tx, _rx) = oneshot::channel();
+ EngineActorRequest::RpcRequest(Box::new(EngineRpcRequest::EngineQuery(Box::new(
+ EngineQueries::State(tx),
+ ))))
+ }
+
+ fn processing_request() -> EngineActorRequest {
+ EngineActorRequest::ProcessFinalizedL2BlockNumberRequest(Box::new(42))
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn rpc_backpressure_stalls_engine_processing_routing() {
+ let cancellation = CancellationToken::new();
+ // Inbound capacity mirrors the production wiring.
+ let (inbound_tx, inbound_rx) = mpsc::channel(1024);
+ let processed = Arc::new(AtomicUsize::new(0));
+
+ let actor = EngineActor::new(
+ cancellation.clone(),
+ inbound_rx,
+ CountingProcessor(Arc::clone(&processed)),
+ StalledRpcReceiver,
+ );
+ let actor_handle = tokio::spawn(actor.start(()));
+
+ // Fill the actor's internal rpc_tx (capacity 1024).
+ for _ in 0..1024 {
+ inbound_tx.send(rpc_request()).await.unwrap();
+ }
+ sleep(Duration::from_millis(100)).await;
+
+ // The 1025th RPC parks the actor on rpc_tx.send().await.
+ inbound_tx.send(rpc_request()).await.unwrap();
+ sleep(Duration::from_millis(100)).await;
+
+ // engine_processing_tx is empty, but the parked select! arm can't pick this up.
+ inbound_tx.send(processing_request()).await.unwrap();
+ sleep(Duration::from_millis(500)).await;
+
+ let routed = processed.load(Ordering::SeqCst);
+ assert_eq!(routed, 0, "processing request blocked by RPC backpressure (routed={routed})");
+
+ // Cancellation drops rpc_tx, releasing the parked send so the actor exits.
+ cancellation.cancel();
+ let _ = timeout(Duration::from_secs(2), actor_handle).await;
+ }
+
+ #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+ async fn engine_processing_routes_when_rpc_drains() {
+ struct DrainingRpcReceiver;
+ impl EngineRpcRequestReceiver for DrainingRpcReceiver {
+ fn start(
+ self,
+ mut request_channel: mpsc::Receiver<EngineRpcRequest>,
+ ) -> JoinHandle<Result<(), EngineError>> {
+ tokio::spawn(async move {
+ while request_channel.recv().await.is_some() {}
+ Ok(())
+ })
+ }
+ }
+
+ let cancellation = CancellationToken::new();
+ let (inbound_tx, inbound_rx) = mpsc::channel(1024);
+ let processed = Arc::new(AtomicUsize::new(0));
+
+ let actor = EngineActor::new(
+ cancellation.clone(),
+ inbound_rx,
+ CountingProcessor(Arc::clone(&processed)),
+ DrainingRpcReceiver,
+ );
+ let actor_handle = tokio::spawn(actor.start(()));
+
+ for _ in 0..1024 {
+ inbound_tx.send(rpc_request()).await.unwrap();
+ }
+ inbound_tx.send(rpc_request()).await.unwrap();
+ inbound_tx.send(processing_request()).await.unwrap();
+ sleep(Duration::from_millis(200)).await;
+
+ let routed = processed.load(Ordering::SeqCst);
+ assert_eq!(routed, 1, "processing request routes when RPC drains");
+
+ cancellation.cancel();
+ let _ = timeout(Duration::from_secs(2), actor_handle).await;
+ }
+}
$ cargo test -p base-consensus-node --lib actors::engine::actor::tests
running 2 tests
test actors::engine::actor::tests::engine_processing_routes_when_rpc_drains ... ok
test actors::engine::actor::tests::rpc_backpressure_stalls_engine_processing_routing ... ok