For the complete documentation index, see llms.txt. This page is also available as Markdown.

76527 bc high unauthenticated optimism outputatblock flood halts sequencer block production via engineactor head of line blocking

Submitted on May 4th 2026 at 18:45:35 UTC by @ZeroExRes for Audit Comp | Base Azul

  • Report ID: #76527

  • Report Type: Blockchain/DLT

  • Report severity: High

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

    • 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:

loop {
    tokio::select! {
        _ = self.cancellation_token.cancelled() => { ... }
        req = self.inbound_request_rx.recv() => {
            match request {
                EngineActorRequest::RpcRequest(rpc_req) => {
                    rpc_tx.send(*rpc_req).await...?;          // blocks here
                }
                // BuildRequest, SealRequest, ProcessUnsafe, Reset, GetPayload, ...
                _ = send_engine_processing_request(...).await?,
            }
        }
    }
}

rpc_tx has capacity 1024. The EngineRpcProcessor that drains it holds 16 concurrent permits, and its drain loop calls recv() before acquire_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:

  1. Attacker spams cold-block optimism_outputAtBlock faster than the drain rate (a few hundred req/s).

  2. The 16 permits fill with slow eth_getProof. rpc_tx reaches capacity.

  3. The actor pulls the next RPC request, hits rpc_tx.send().await, blocks.

  4. 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.

References

  • crates/consensus/service/src/actors/engine/actor.rs — vulnerable select loop

  • crates/consensus/service/src/actors/engine/rpc_request_processor.rs — semaphore + recv-then-acquire

  • 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.

Was this helpful?