76500 bc high batcher reorg reset skips canonical blocks via stale safe head
#76500 [BC-High] Batcher Reorg Reset Skips Canonical Blocks via Stale Safe Head
Submitted on May 4th 2026 at 17:35:16 UTC by @v_c0d35 for Audit Comp | Base Azul
Report ID: #76500
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
Description
Batcher Reorg Reset Skips Canonical Blocks via Stale Safe Head
Brief/Intro
The Base batcher receives an L2 reorg event that includes the new post-reorg L2 head, but the driver ignores that event head and resets catchup from a separately watched safe-head number. Because the watched safe-head value is only updated when it increases, it can remain stale-high after the rollup node's actual safe_l2 regresses. In production, this can make the batcher skip canonical L2 blocks after a reorg and submit later blocks that verifiers cannot safely derive until the missing prefix is posted, delaying L1 data availability and safe/finalized progress for affected user transactions.
Vulnerability Details
The reorg event type carries the block reference that should drive the reset. L2BlockEvent::Reorg is documented as a signal that all state should be rewound to new_safe_head:
Source: crates/batcher/source/src/event.rs#L7-L15
The hybrid L2 block source creates this event when it observes the same block number with a different hash. It constructs new_safe_head directly from the reorg block and emits it as the event payload:
Source: crates/batcher/source/src/hybrid.rs#L76-L118
The driver receives the event head, but does not use it for the reset. The event is converted into DriverEvent::Reorg(new_safe_head):
Source: crates/batcher/core/src/driver.rs#L345-L351
In the actual reset handler, the event head is only logged as reorg_head. The reset target is computed from self.safe_head_rx.borrow() instead:
Source: crates/batcher/core/src/driver.rs#L182-L193
The same pattern also exists in the block-ingestion reorg path, where add_block detects a parent mismatch but catchup is again reset from the watched safe head rather than from the returned block/reorg context:
Source: crates/batcher/core/src/driver.rs#L250-L268
This would be less dangerous if the watched safe head could never be stale above the rollup node's actual safe head. However, the watched value is explicitly an advancing-only u64. At startup, the batcher reads optimism_syncStatus.safe_l2.block_info.number:
Source: crates/batcher/service/src/service.rs#L309-L318
It then creates a watch channel from that number and starts a poller:
Source: crates/batcher/service/src/service.rs#L452-L461
The poller obtains the number from optimism_syncStatus.safe_l2:
Source: crates/batcher/service/src/safe_head_poller.rs#L20-L25
But it only mutates the watched value when the new number is greater than the old value:
Source: crates/batcher/service/src/safe_head_poller.rs#L63-L74
The rollup RPC's safe_l2 is not monotonic in the same way. The RPC response is derived from the engine sync state's safe_head():
Source: crates/consensus/rpc/src/rollup.rs#L59-L72
EngineSyncState::apply_update replaces safe_head with an incoming safe_head update without an increasing-number guard:
Source: crates/consensus/engine/src/state/core.rs#L77-L108
As a result, a reorg or safe-head reconciliation can make optimism_syncStatus.safe_l2 move from S down to R, while the batcher's watch channel remains stuck at S. If a reorg event with new_safe_head = R is delivered while the watch still contains S, the driver calls reset_catchup(S + 1) instead of reset_catchup(R + 1).
The catchup source obeys the supplied start number exactly. While next_sequential is set, it fetches block n, increments to n + 1, and returns the block:
Source: crates/batcher/service/src/source.rs#L45-L88
The hybrid source's reset path simply clears deduplication state and delegates to that poller:
Source: crates/batcher/source/src/hybrid.rs#L130-L138
The encoder reset removes the previous tip and pending channel state:
Source: crates/batcher/encoder/src/encoder.rs#L644-L656
After that reset, the encoder's parent-hash continuity check only runs when self.blocks is non-empty:
Source: crates/batcher/encoder/src/encoder.rs#L339-L351
This means the first block delivered after the wrong reset point is accepted as the beginning of a new channel even if the canonical blocks between the reorg head and the stale watched safe head were never re-delivered to the encoder.
A minimal deterministic sequence is:
The safe-head watch records
S = 42.The engine/RPC safe head later regresses to
R = 5.The safe-head poller receives
5, but ignores it because5 > 42is false.The L2 block source emits
L2BlockEvent::Reorg { new_safe_head: R }.The driver logs
reorg_head = 5, but callsreset_catchup(43).Sequential catchup fetches and encodes blocks
43, 44, 45.Canonical blocks
6..42are never fed to the encoder in that reset cycle.
This directly violates the batcher protocol requirement that channels cover contiguous, non-overlapping L2 block ranges and that no blocks be skipped between channels. The public Base batcher spec states that after a reorg the batcher must discard pending encoding/submission state and restart from the new canonical chain tip, and that no blocks may be skipped between consecutive channels:
Source: docs/specs/pages/protocol/batcher.md#L39-L45
The downstream derivation rules then prevent later batches from advancing the safe head until the missing prefix is available. A batch whose timestamp is ahead of the next expected L2 timestamp is treated as future, and a batch whose parent hash does not equal the current safe L2 head hash is dropped:
Source: docs/specs/pages/protocol/consensus/derivation.md#L624-L631
Impact Details
The batcher is the data availability service that posts L2 sequencer data to L1 so validators can reconstruct the L2 chain from L1. The public protocol overview describes the batcher as compressing L2 transaction data into channel frames and posting them to L1, allowing validators to independently reconstruct the L2 chain:
Source: docs/specs/pages/protocol/overview.md#L209-L213
When this bug is triggered, the affected batcher can omit the canonical block range R+1..S after a reorg and continue submitting later blocks from S+1. Those later submissions do not repair the gap because the derivation pipeline expects the next batch after the current safe head. The missing prefix prevents safe derivation from progressing through the skipped user transactions until the omitted canonical range is posted in a valid channel and included on L1.
The user-visible consequences are delayed L1 data availability, delayed safe/finalized progress for the affected L2 transactions, delayed downstream proof/proposer workflows that depend on safe or finalized L2 state, and wasted L1 submission fees for later channels that cannot be used until the missing prefix is supplied. If the sequencer continues producing blocks while the batcher is posting from the wrong cursor, the unsafe/safe gap can grow and the batcher's data availability backlog can increase.
The delay scales with S - R. In the concrete sequence above, the batcher skips 37 L2 blocks (6..42) and first submits block 43. The Base derivation spec describes the L2 block time as a configurable parameter and notes 2 seconds for the Optimism/Base-style block cadence:
Source: docs/specs/pages/protocol/consensus/derivation.md#L86-L100
At a 2 second cadence, the example 37-block gap corresponds to 74 seconds of L2 block data whose canonical batcher data is omitted before the first post-reset submission. Larger stale-safe-head gaps produce proportionally larger delays. Recovery requires the missing canonical range to be reposted from the correct cursor, for example through a corrected reset path or operator recovery that starts from the actual post-reorg safe head.
The default batcher CLI configuration polls the safe head every 1 second and uses a 48 second transaction resubmission timeout:
Sources: bin/batcher/src/cli.rs#L79-L81, bin/batcher/src/cli.rs#L139-L145
Those defaults do not correct the skipped canonical range: polling lower safe_l2 values is suppressed by the safe-head poller, and resubmission only retries pending submissions from the current encoder/submission state. The stale high reset cursor remains the root cause until the batcher is made to replay from the actual reorg head.
References
Proof of Concept
Proof of Concept: Batcher Reorg Reset Skips Canonical Blocks via Stale Safe Head
This guide reproduces the vulnerability on the public base/base repository at tag v0.8.0-rc.28. It adds three deterministic tests:
base-consensus-engine: proves engine sync state can movesafe_l2downward.base-batcher-service: proves the batcher safe-head watch ignores that downward move and remains stale-high.base-batcher-core: proves a reorg event then resets catchup from the stale watched safe head, and a realBatchEncodersubmits later blocks while omitting the canonical range after the reorg head.
6. Run the PoC
From the repository root, run:
The first run may compile a large part of the workspace. If the checkout is on a small temporary filesystem, set CARGO_TARGET_DIR to a path with enough free space before running the commands, for example:
Expected result:
The final base-batcher-core test proves the full vulnerable transition:
reorg event head
R = 5watched safe head
S = 42actual reset target recorded as
43expected reset target would be
6real encoder accepts and submits blocks
[43, 44, 45]canonical blocks
6..42are omitted from the encoder/submission paththe modeled omitted range is 37 L2 blocks, equal to 74 seconds at a 2 second L2 block cadence
7. Why This Reproduces the Vulnerability
The three tests connect the complete state transition:
base-consensus-engineshows the rollup node state can movesafe_l2downward.base-batcher-serviceshows the batcher watch channel ignores that downward value and remains stale-high.base-batcher-coreshows the driver uses the stale watched value instead of the reorg event head, then the real encoder submits later blocks while skipping the canonical range after the reorg head.
This is the same logic used by the public v0.8.0-rc.28 batcher source paths:
Reorg event payload:
crates/batcher/source/src/event.rsReorg detection and event emission:
crates/batcher/source/src/hybrid.rsReorg reset decision:
crates/batcher/core/src/driver.rsSafe-head watch updates:
crates/batcher/service/src/safe_head_poller.rsSequential catchup:
crates/batcher/service/src/source.rsReal batch encoder reset and block acceptance:
crates/batcher/encoder/src/encoder.rs
Was this helpful?