74856 bc medium replay of stale sequencer signed unsafe gossip rewinds unsafe head and causes unintended l2 reorg behavior
Submitted on Apr 25th 2026 at 11:15:08 UTC by @vivekd for Audit Comp | Base Azul
Report ID: #74856
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/base/base/tree/v0.8.0-rc.24
Impacts:
A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk
Description
Brief/Intro
Base-native consensus code accepts a replayed, previously valid sequencer-signed unsafe gossip payload and can overwrite the current unsafe_head with that older block. If the target is the active sequencer and its gossip dedup state has been cleared, the next sequencing tick rebuilds from the stale ancestor and replaces the current unsafe branch. This causes unintended L2 contract behavior on latest / unsafe state, without demonstrated safe_head, finalized_head, bridge, or direct funds impact.
Vulnerability Details
The vulnerable code is in the in-scope Base-native consensus crates, not in devnet. devnet is out of scope as an asset (Contest_Rules.md:227), but it is used here only as a local harness to reproduce the issue in the in-scope consensus path.
The affected flow is:
gossip -> BlockHandler validation -> NetworkActor forwarding -> ProcessUnsafeL2Block -> InsertTask -> SynchronizeTask -> EngineSyncState::apply_update -> unsafe_head watch channel -> PayloadBuilder::build()Inbound unsafe gossip is received by the network actor and forwarded to the engine with no monotonicity or parent-continuity check (scope/base/crates/consensus/service/src/actors/network/actor.rs:193, scope/base/crates/consensus/service/src/actors/network/actor.rs:198, scope/base/crates/consensus/service/src/actors/engine/engine_request_processor.rs:584). Gossip validation checks only the timestamp acceptance window, payload hash, version-specific validity, exact-hash dedup, and sequencer signature. It does not require the incoming block to be newer than the current unsafe_head or to be its direct child (scope/base/crates/consensus/gossip/src/block_validity.rs:167).
Once accepted, InsertTask always turns the payload into EngineSyncStateUpdate { unsafe_head: Some(new_unsafe_ref), ... } and calls SynchronizeTask (scope/base/crates/consensus/engine/src/task_queue/tasks/insert/task.rs:129). SynchronizeTask only rejects the update when unsafe_head < finalized_head; it does not enforce monotonicity or parent continuity (scope/base/crates/consensus/engine/src/task_queue/tasks/synchronize/task.rs:118). EngineSyncState::apply_update then blindly overwrites unsafe_head (scope/base/crates/consensus/engine/src/state/core.rs:92).
This is not only a CL-local rewind. The successful PoC logs show the EL accepts the replayed stale block and then serves it as the current unsafe head for later sequencing. Base's own engine code documents the same EL behavior in the reset path: a stale forkchoice_updated can return Valid and cause reth to set that stale block as canonical (scope/base/crates/consensus/service/src/actors/engine/engine_request_processor.rs:596).
This matters most on the active sequencer because PayloadBuilder::build() always reads the current unsafe_head from the engine watch channel before starting the next block (scope/base/crates/consensus/service/src/actors/sequencer/build.rs:61). The sequencer's stale-build guard handles only:
current_head.number > build_parent.number
same-height / different-hash reorgs
It misses the backward-regression case current_head.number < build_parent.number (scope/base/crates/consensus/service/src/actors/sequencer/actor.rs:146). After replay, the in-flight build is dropped with UnsafeHeadChangedSinceBuild, and the next sequencing tick rebuilds from the stale ancestor.
The same unsafe-head regression also affects restarted non-sequencer followers: they use the same gossip-to-engine path, and rollup RPC exposes unsafe_l2 directly from the engine sync state (scope/base/crates/consensus/rpc/src/rollup.rs:59, scope/base/crates/consensus/rpc/src/rollup.rs:69). So a restarted follower can also serve regressed latest / unsafe state to RPC consumers even if it is not sequencing.
Real-World Preconditions
The attacker needs:
a captured authentic sequencer-signed unsafe gossip envelope,
replay before that envelope expires from the gossip timestamp acceptance window: not more than 60 seconds in the past and not more than 5 seconds in the future (scope/base/crates/consensus/gossip/src/block_validity.rs:170),
a target whose dedup state has been cleared, such as a restarted active sequencer or follower,
and the old block must still be valid/importable by the EL.
The manual restart downgrade clause does not apply because the report relies on normal restart-induced dedup loss, not on an assumption that operators fail to restart (Contest_Rules.md:250). The timing window is operationally reachable: in the successful PoC, restart, reconnect, and replay completed in roughly 7 seconds, well inside the acceptance window.
Impact Details
The strongest supported impact is Medium unintended smart contract behavior. After replay, the current unsafe branch is replaced by a different branch rooted at an older ancestor. Any transaction or contract state change that existed only on the displaced unsafe branch disappears from latest / unsafe state until it is re-included. The PoC proves this branch-replacement primitive directly by showing:
a restarted sequencer bootstraps at unsafe head 4,
replayed stale block 2 becomes the new unsafe_head,
the in-flight build is dropped because the unsafe head changed underneath it,
sequencing resumes from parent 2,
and different 3/4/5 blocks are produced.
I am not claiming corruption of safe_head or finalized_head, bridge impact, direct funds loss, or a persistent network partition. Medium is therefore the correct ceiling.
Proof of Concept
This PoC is a runnable patch against base/base tag v0.8.0-rc.24. It uses devnet only as a local harness. The vulnerable path being exercised is in the in-scope Base-native consensus crates:
Patch Setup
Add the two test-only dependencies needed by the harness:
After adding those dependencies, Cargo.lock should include base-common-rpc-types-engine and discv5 in the devnet package dependency list. Running the test command below will update the lockfile if needed.
Create devnet/tests/unsafe_gossip_replay.rs with the following contents:
What This Code Does
Uses
devnetonly as a local runner; the bug itself is in the in-scope consensus crates.Captures a real sequencer-signed unsafe gossip envelope for stale block 2.
Waits until the sequencer advances to block 4.
Restarts the sequencer to clear gossip dedup state.
Replays the exact captured envelope over real libp2p gossip.
Asserts that the restarted sequencer's
unsafe_headregresses to block 2.Asserts that block 3 is rebuilt with a different hash, proving unsafe branch replacement.
How to Run
If macOS fails because .cargo/config.toml points to a missing ld64.lld, temporarily move that file aside before running the test:
Logs
Was this helpful?