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

75485 bc critical unbounded snappy decompression in gossipsub message id fn causes per message memory spike of 428 mib before validation

Submitted on Apr 29th 2026 at 12:26:55 UTC by @Outliers for Audit Comp | Base Azul

  • Report ID: #75485

  • Report Type: Blockchain/DLT

  • Report severity: Critical

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

  • 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 compute_message_id function in Base's Rust gossip layer performs unbounded snappy decompression on every received GossipSub message before any validation, rate limiting, or peer scoring can intervene. A single attacker-crafted 10 MiB wire message can force a transient peak memory allocation of approximately 428 MiB on the receiving node. With no fees, no staking, and free libp2p peer identities, an attacker can sustain this allocation pressure at scale — potentially OOM-killing the sequencer and halting block production on Base mainnet.

Vulnerability Details

The vulnerability lives in crates/consensus/gossip/src/config.rs, in the compute_message_id function registered as GossipSub's message_id_fn:

fn compute_message_id(msg: &Message) -> MessageId {
    let mut decoder = Decoder::new();
    let id = decoder.decompress_vec(&msg.data).map_or_else(
        |_| {
            let domain_invalid_snappy: Vec<u8> = vec![0x0, 0x0, 0x0, 0x0];
            sha256([domain_invalid_snappy.as_slice(), msg.data.as_slice()].concat().as_slice())
                [..20].to_vec()
        },
        |data| {
            let domain_valid_snappy: Vec<u8> = vec![0x1, 0x0, 0x0, 0x0];
            sha256([domain_valid_snappy.as_slice(), data.as_slice()].concat().as_slice())[..20]
                .to_vec()
        },
    );
    MessageId(id)
}

This function is invoked by libp2p-gossipsub for every received message as part of duplicate detection — before any application validation, peer scoring, or throttling can run. The execution order is fixed by libp2p:

  1. Wire bytes received from peer

  2. max_transmit_size check (10 MiB cap on compressed wire size)

  3. message_id_fn invoked — allocations occur here

  4. LRU dedup cache lookup

  5. Application validation and peer scoring

  6. Forwarding to mesh peers

Steps 5–6 cannot prevent Step 3. The commonly cited mitigations (MAX_VALIDATE_QUEUE, GLOBAL_VALIDATE_THROTTLE, ConnectionGater) all operate after message_id_fn returns.

Two unbounded allocations per call

Allocation A — decompression. decompress_vec in snap-1.1.1 allocates a buffer sized from the snappy varint header verbatim, before any decompression occurs:

decompress_len reads the declared output size from the header without any application-level cap. snap's internal ceiling is 2^32 - 1 (~4 GiB) — not a defense, that is the attacker's upper bound per message. Within the 10 MiB wire limit, snappy's encoding allows up to ~213 MiB of decompressed output using 3-byte copy tags that each expand to up to 64 bytes of output (~21× amplification per tag).

Allocation B — concatenation. [domain, data].concat() allocates and copies the entire decompressed buffer a second time before SHA-256 hashes it. Both buffers coexist in memory while SHA-256 runs, doubling the peak commit to ~2× the decompressed size.

Critically, the decompression buffer allocation in decompress_vec happens before any decompression error can be returned. Even a malformed "bomb" that would fail decompression still triggers the initial vec![0; decompress_len(input)?] allocation. The attack does not require a decompression error.

Why MAX_GOSSIP_SIZE doesn't help

max_transmit_size(MAX_GOSSIP_SIZE) bounds the compressed wire size. The snappy varint header is free to declare a decompressed size up to 2^32 - 1 regardless of the wire payload's actual size. A fully wire-conformant 9 MiB payload can declare and force a 213 MiB decompressed output.

Regression from op-node

In op-node (Go), gossip messages are decompressed via a snappy.Reader bounded by maxGossipSize. The Base Rust port uses decompress_vec with no such bound — the bounded-decompression invariant was lost in the port.

Impact Details

Measured numbers

Direct decompression (snap-1.1.1):

  • Wire payload: ~9 MiB (within MAX_GOSSIP_SIZE)

  • Declared output size: ~213 MiB

  • VmRSS growth: ~213 MiB

  • Amplification: 21.3× wire-to-decompressed

Through compute_message_id (production code path):

  • Wire payload: ~9 MiB

  • VmHWM (peak resident) growth: ~428 MiB

  • Amplification: ~42× wire-to-peak-RSS

VmHWM is the correct metric here — both buffers are dropped before compute_message_id returns, so post-call VmRSS (~2 MiB) is misleading. The attack is rate-based: the transient 428 MiB spike repeats for every received message, and concurrent libp2p receive tasks stack these spikes multiplicatively.

Severity mapping

  • High — network processing node resource breach: A single message forces ~428 MiB peak commit. Under concurrent message processing (libp2p mesh fanout of D=6–12 peers per publish), N simultaneous calls force N × 428 MiB allocation — well beyond any reasonable process memory budget.

  • High — sequencer downtime: Base uses a centralized sequencer. An OOM-killed sequencer requires process restart, mesh re-formation, peer re-discovery, and L1 attribute resync — each independently taking several seconds. A single successful OOM event exceeds 5× the 2-second target block time, satisfying the program's High threshold. Sustained attack keeps the sequencer down for as long as the attacker maintains message throughput.

  • Economic asymmetry: Attacker cost is ~10 MiB outbound bandwidth plus a free self-generated libp2p keypair (no fees, no stake, no rate limit on publish). Victim cost is ~428 MiB peak memory plus CPU for SHA-256 over 213 MiB. With GossipSub mesh fanout, one published message hits multiple mesh neighbors simultaneously. A sybil cluster of ~10 peers (cost: zero) provides full mesh coverage of a small validator set and can sustain GiB/s allocation pressure on each victim node for MiB/s attacker bandwidth.

  1. Pre-flight length check — read the snappy header without allocating, and reject if the declared size exceeds MAX_GOSSIP_SIZE before calling decompress_vec.

  2. Eliminate the .concat() doubling — replace [domain, data].concat() with sequential SHA-256 updates (Sha256::new().update(domain).update(data).finalize()), reducing peak commit from ~2× to 1× the decompressed size.

  3. (Optional) fast_message_id_fn — set a cheap hash of the compressed bytes as the fast dedup key, avoiding decompression entirely on duplicate messages.

With fixes 1 and 2 applied, the per-message peak commit drops from ~428 MiB to at most MAX_GOSSIP_SIZE (10 MiB).

Proof of Concept

Two test cases added to crates/consensus/gossip/src/config.rs. Both pass against the live codebase at v0.8.0-rc.28.

POC 1 — wire-format primitive

Output:

POC 2 — production code path

Output:

VmHWM (peak resident set size, monotonic) is used because both allocations are released before compute_message_id returns. Post-call VmRSS shows only ~2 MiB growth and would understate the true allocation.

Reproducing

Both tests pass on Linux x86_64 against snap-1.1.1 (confirmed via Cargo.lock) and base-consensus-gossip v0.8.0-rc.28.

Was this helpful?