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

75333 bc low off by one in batcher default frame size causes permanent blob encoding failure and synchronous livelock halting l2 finalization

Submitted on Apr 28th 2026 at 14:52:08 UTC by @Omisanin0 for Audit Comp | Base Azul

  • Report ID: #75333

  • Report Type: Blockchain/DLT

  • Report severity: Low

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

  • Impacts:

    • Network not being able to confirm new transactions (total network shutdown)

Description

Brief/Intro

The Base batcher's default max_frame_size of 130,044 bytes does not reserve space for the 1-byte DERIVATION_VERSION_0 prefix that BlobEncoder::encode_packed() prepends when packing frames into EIP-4844 blobs. Every near-capacity frame exceeds the blob data limit by exactly 1 byte, returning a DataTooLarge error. The error handler requeues the identical immutable frame and retries in a synchronous loop with no async yield points, no iteration cap, and no backoff. This permanently blocks the batcher's driver loop, halting all L1 data availability submissions, freezing L2 finalization, and locking all pending L2-to-L1 withdrawals. Restarting the batcher with default configuration reproduces the failure immediately.

Vulnerability Details

The vulnerability sits at the intersection of three components in the batcher pipeline. Each component works correctly in isolation, but together they produce a fatal off-by-one mismatch.

1. Frame sizing in output_frame() (crates/batcher/comp/src/channel_out.rs:140-168)

When the encoder drains compressed data into frames, it calls output_frame(max_size) where max_size comes from the config default of 130,044 (equal to BLOB_MAX_DATA_SIZE). The function subtracts the 23-byte frame header overhead and an optional 1-byte compression version prefix to compute the data budget:

For frame 0 with Brotli compression: prefix_len = 1, so data budget = 130044 - 23 - 1 = 130020. The frame payload becomes [0x01 brotli_version] ++ [130020 compressed bytes] = 130,021 bytes.

For subsequent frames: prefix_len = 0, so data budget = 130044 - 23 = 130021. The frame payload is 130,021 raw compressed bytes.

In both cases, frame.data.len() = 130021.

2. Blob encoding in encode_packed() (crates/batcher/blobs/src/encoder.rs:52-68)

When submitting to L1, the submission queue calls BlobEncoder::encode_packed() to pack frames into a blob. This function prepends a 1-byte DERIVATION_VERSION_0 prefix before the encoded frames:

For a single frame with data.len() = 130021:

This exceeds BLOB_MAX_DATA_SIZE (130,044) by exactly 1 byte, producing Err(DataTooLarge).

The output_frame() function correctly accounts for the per-frame overhead (23 bytes) and the per-frame compression prefix (1 byte for Brotli), but it does not account for the per-blob DERIVATION_VERSION_0 prefix that encode_packed() adds at the blob level. These are two different version bytes at two different layers, and only the frame-level one is subtracted from the budget.

3. Synchronous infinite retry in submit_pending() (crates/batcher/core/src/submissions.rs:109-125)

When encode_packed() fails, the error handler requeues all frame IDs and continues to the next loop iteration:

The requeue() call (crates/batcher/encoder/src/encoder.rs:594-618) rewinds the channel cursor to frame_start, so the next next_submission() call returns the exact same Arc<Frame> with the same immutable 130,021-byte payload.

The entire error path is synchronous: try_acquire_owned() on the semaphore is sync, next_submission() reads from an in-memory channel and is sync, encode_packed() is a pure computation and is sync, requeue() is a cursor reset and is sync, and continue jumps back to the loop top. No .await is ever reached on the error path. The tokio task never yields, permanently blocking the executor thread.

4. Driver main loop is blocked (crates/batcher/core/src/driver.rs:153-157)

Since submit_pending never returns, the driver cannot process new L1 heads, new L2 blocks, shutdown signals, or any other event.

Why this triggers under normal operation

The bug fires whenever compressed channel output fills a frame to capacity, which is the standard operating condition:

  • The default target_frame_size and max_frame_size are both 130,044 (config.rs:97-98, cli.rs:96-97). There is no separate --max-frame-size CLI flag; cli.rs:227 hardcodes max_frame_size = target_frame_size.

  • The ShadowCompressor's fullness check has a secondary issue: shadow.rs:81 uses newbound = data.len() as u64 (the current write size, not the cumulative total), so the "channel full" signal never triggers for normal-sized block writes. Channels accumulate data until the RLP byte limit (10-100 MB) or the channel timeout (default 2 L1 blocks, ~24 seconds).

  • At Base mainnet throughput, even 24 seconds of L2 blocks produces compressed output well above 130 KB, guaranteeing that output_frame(130044) produces a frame with data.len() = 130021 on every cycle.

The developers' own test code confirms the correct math

The test helper blob_filling_submission() at crates/batcher/core/src/driver.rs:436-448 correctly subtracts 1 for DERIVATION_VERSION_0:

The production default uses 130,044. The test helper uses 130,043 (after subtracting 1). The discrepancy is the bug.

Additionally, the test test_blob_encoding_failure_requeues_submission at driver.rs:508-548 defines const OVERSIZED: usize = 130_021 and documents that this size guarantees DataTooLarge. Production output_frame(130044) produces exactly data.len() = 130021. The test exercises the single-requeue path but uses a mock that does not feed the frame back into the queue, so the infinite retry loop is never tested.

Impact Details

The batcher is the sole component responsible for posting L2 batch data to L1 via EIP-4844 blobs. When it enters this livelock:

  • L2 data availability halts. No new batch data reaches L1. The derivation pipeline on any verifier or prover node has no new data to consume.

  • L2 finalization on L1 freezes. TEE and ZK provers require DA data to generate proofs. Without proofs, no proposals can be finalized. The proof window (7 days single-proof, 1 day dual-proof) cannot even begin.

  • All pending L2-to-L1 withdrawals are frozen. Withdrawals through the bridge require a finalized L2 state root on L1. With no new finalized state, no withdrawal can complete. Every user with funds in the bridge or pending withdrawal is affected.

  • Restart does not resolve the issue. The default configuration reproduces the bug immediately upon processing the first full channel. An operator would need to discover the exact off-by-one, determine the correct workaround value (130,043), and pass a non-default --target-frame-size flag that is not documented as a fix for this issue. There is no --max-frame-size CLI flag.

This matches the in-scope impact "Network not being able to confirm new transactions (total network shutdown)" — the batcher is the single point of L2-to-L1 data submission, and its failure halts the entire finalization pipeline.

Regarding the downgrade clause about restarting services with different configurations: this does not apply here because the default configuration is the broken configuration. Restarting with defaults reproduces the livelock within seconds of processing the first full channel. The "different configuration" that fixes the bug requires knowing the exact internal arithmetic mismatch, which is the vulnerability itself.

References

All paths relative to repository root (base/base at tag v0.8.0-rc.28):

  • crates/batcher/encoder/src/config.rs:97-98 — Default max_frame_size = 130044 (should be 130043)

  • bin/batcher/src/cli.rs:96-97,227 — CLI default 130044, hardcodes max_frame_size = target_frame_size

  • crates/batcher/comp/src/channel_out.rs:140-168output_frame() frame data budget calculation

  • crates/batcher/blobs/src/encoder.rs:52-59,66-68encode_packed() prepends DERIVATION_VERSION_0, size check rejects

  • crates/batcher/core/src/submissions.rs:109-125 — Synchronous requeue-and-retry loop

  • crates/batcher/encoder/src/encoder.rs:232,291,594-618 — Frame drain and cursor rewind on requeue

  • crates/batcher/core/src/driver.rs:153-157 — Main loop blocked by submit_pending

  • crates/batcher/core/src/driver.rs:436-448 — Test helper with correct math (-1 for DERIVATION_VERSION_0)

  • crates/batcher/core/src/driver.rs:508-548 — Test defining OVERSIZED = 130021 as the failure boundary

  • crates/batcher/comp/src/shadow.rs:70-98 — ShadowCompressor per-write fullness check (contributing factor)

Proof of Concept

Step 1 — Arithmetic proof of the off-by-one

The default max_frame_size is 130,044. output_frame() subtracts 23 bytes of frame overhead, producing frames with data.len() = 130021. encode_packed() then constructs the blob payload as 1 (DERIVATION_VERSION_0) + 23 (frame header) + 130021 (frame data) = 130045. Since BLOB_MAX_DATA_SIZE = 130044, the payload exceeds the limit by exactly 1 byte.

Step 2 — Direct encode_packed failure with production-sized frame

A Frame is created with data.len() = 130021 (the exact size that output_frame(130044) produces). BlobEncoder::encode_packed() is called with this frame and returns Err(DataTooLarge { size: 130045 }).

Step 3 — encode_packed succeeds with corrected frame size

A Frame is created with data.len() = 130020 (the size that output_frame(130043) would produce). BlobEncoder::encode_packed() returns Ok(blob). The blob payload is 1 + 23 + 130020 = 130044, which is exactly BLOB_MAX_DATA_SIZE.

Step 4 — Real ChannelOut with ShadowCompressor produces the oversized frame

A ChannelOut<ShadowCompressor> is created with the production compressor configuration (Brotli10, target 130044). Incompressible data is written to the compressor to ensure the compressed output exceeds 130,021 bytes. After closing the channel, output_frame(130044) is called — the same call the production encoder makes at encoder.rs:232. The resulting frame has data.len() = 130021 and data[0] = 0x01 (Brotli version byte). Passing this real frame to BlobEncoder::encode_packed() returns Err(DataTooLarge { size: 130045 }).

Step 5 — Livelock pattern: same frame fails on every retry

The requeue-and-retry behavior from submissions.rs:109-125 is simulated. The same Arc<Frame> (immutable, reference-counted) is passed to encode_packed() on each iteration. All 10 simulated iterations fail with the identical DataTooLarge error. In production, this loop has no iteration limit, no backoff, and no async yield point. The frame cannot change because it is behind an Arcrequeue() only rewinds the channel cursor, it does not modify or resize the frame data.

## Step 6 — Fix verification: max_frame_size = 130043 resolves the issue

A fresh ChannelOut<ShadowCompressor> is created with identical configuration. output_frame(130043) is called instead of output_frame(130044). The resulting frame has data.len() = 130020. BlobEncoder::encode_packed() returns Ok(blob) with a payload of exactly 130,044 bytes — a perfect fit. The developers' own test helper blob_filling_submission() uses BLOB_MAX_DATA_SIZE - 1 - FRAME_OVERHEAD = 130020 as the correct data length, confirming this is the intended arithmetic.

Full PoC source

To run: place these two files inside crates/batcher/poc-f1001/ in the base/base repository at tag v0.8.0-rc.28. The workspace already picks it up via the crates/batcher/* glob in the root Cargo.toml. Then run cargo run -p poc-f1001 from the repository root. No environment variables, no network access, no external services.

crates/batcher/poc-f1001/Cargo.toml

crates/batcher/poc-f1001/src/main.rs

PoC output

Was this helpful?