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_sizeandmax_frame_sizeare both 130,044 (config.rs:97-98,cli.rs:96-97). There is no separate--max-frame-sizeCLI flag;cli.rs:227hardcodesmax_frame_size = target_frame_size.The
ShadowCompressor's fullness check has a secondary issue:shadow.rs:81usesnewbound = 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 withdata.len() = 130021on 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-sizeflag that is not documented as a fix for this issue. There is no--max-frame-sizeCLI 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— Defaultmax_frame_size = 130044(should be 130043)bin/batcher/src/cli.rs:96-97,227— CLI default 130044, hardcodesmax_frame_size = target_frame_sizecrates/batcher/comp/src/channel_out.rs:140-168—output_frame()frame data budget calculationcrates/batcher/blobs/src/encoder.rs:52-59,66-68—encode_packed()prependsDERIVATION_VERSION_0, size check rejectscrates/batcher/core/src/submissions.rs:109-125— Synchronous requeue-and-retry loopcrates/batcher/encoder/src/encoder.rs:232,291,594-618— Frame drain and cursor rewind on requeuecrates/batcher/core/src/driver.rs:153-157— Main loop blocked bysubmit_pendingcrates/batcher/core/src/driver.rs:436-448— Test helper with correct math (-1for DERIVATION_VERSION_0)crates/batcher/core/src/driver.rs:508-548— Test definingOVERSIZED = 130021as the failure boundarycrates/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 Arc — requeue() 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?