74913 bc medium unbounded snappy decompression in libp2p gossip handling enables amplification class resource exhaustion of base consensus follower fleet
Submitted on Apr 25th 2026 at 21:02:27 UTC by @gzeon for Audit Comp | Base Azul
Report ID: #74913
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/base/base/tree/v0.8.0-rc.24
Impacts:
Shutdown of greater than or equal to 30% of network processing nodes without brute force actions, but does not shut down the network
Description
Brief / Intro
Base's libp2p gossipsub handling decompresses every incoming gossip message without any decompressed-size cap, despite the MAX_GOSSIP_SIZE = 10 MB constant already enforced on the compressed side. Two distinct decompression sinks fire per delivered message — one in compute_message_id (on every received message, before dedup), and one in BlockHandler::handle via NetworkPayloadEnvelope::decode_v{1..4}. Snappy's raw format permits up to 21.3:1 expansion under that 10 MB cap. Empirically: a single 9.98 MB compressed payload commits 213 MB of RSS and 0.18 s of CPU on every receiving peer per sink.
Attackers are unauthenticated libp2p peers joining the gossip mesh; no Base credentials, no infra access, no spoofing. On Base's recommended hardware (32 GB RAM, 8+ vCPU per Base node-operator docs), a single attacker at ~4 Gbps uplink saturates ≥30 % of the gossipsub task's CPU on every in-mesh peer and pressures co-resident base-reth-node, RPC, and flashblocks services with 213 MB RSS spikes per message — qualifying for MEDIUM under "Increasing network processing node resource consumption by at least 30 % without brute force actions."
The fix is a 5-line decompress_len pre-check using the existing MAX_GOSSIP_SIZE constant.
Vulnerability Details
The two decompression sinks
Sink 1. crates/consensus/gossip/src/config.rs:104-119 — compute_message_id runs for every received gossip message before the duplicate cache is consulted. Decompression therefore fires on first receipt regardless of later dedup:
Registered as the gossipsub message-id function at crates/consensus/gossip/src/config.rs:88-93 alongside .max_transmit_size(MAX_GOSSIP_SIZE) (compressed-only cap of 10 MB).
Sink 2. crates/consensus/gossip/src/handler.rs:50-72 — BlockHandler::handle selects one of decode_v{1..4} based on the message's topic and runs it on the same compressed bytes:
Each decode_vN calls snap::raw::Decoder::decompress_vec(data) again at:
crates/common/rpc-types-engine/src/envelope.rs:258(decode_v1)crates/common/rpc-types-engine/src/envelope.rs:302(decode_v2)crates/common/rpc-types-engine/src/envelope.rs:346(decode_v3)crates/common/rpc-types-engine/src/envelope.rs:402(decode_v4)
For each gossip message that survives id-dedup and is routed to its topic handler, both decompression sinks fire in sequence on the same byte buffer.
Topic subscription matrix
Topic instances created in BlockHandler::new (crates/consensus/gossip/src/handler.rs:99-102); all four returned as the accepted topic set by topics() (handler.rs:79-87):
Every follower subscribes to all four topics. compute_message_id hashes only msg.data (topic is not part of the digest), so an attacker who publishes the byte-identical payload on multiple topics gets dedup'd globally after the first decompression. The PoC therefore varies one byte per topic so each topic-message has a distinct id and runs its own decompression — a 4-byte total cost to the attacker per round.
Why snap::raw::Decoder::decompress_vec is unbounded
Source-level behaviour (snap-1.1.1/src/decompress.rs:105-110):
The varint header carries the declared decompressed length, capped only by MAX_INPUT_SIZE = u32::MAX (~4 GB). No application-level cap exists in Base's call sites; both sinks pass attacker-controlled bytes directly to decompress_vec.
vec![0; N] calls __rust_alloc_zeroed, which on glibc/Linux above the M_MMAP_THRESHOLD = 128 KB issues mmap(MAP_ANONYMOUS). Pages remain kernel zero pages until first written. The decoder then writes the decompressed payload into the buffer, physically committing the pages. A "tiny varint header declaring 4 GB" attack errors out fast (HeaderMismatch) without committing many pages. A legit-expansion attack — chained copy-2-byte len=64 offset=1 instructions — produces a snappy-spec-valid stream that commits real RSS at the snappy max ratio of 21.3:1.
Snappy-spec ceiling on per-message decompressed size
copy-1-byte (3-bit len, 11-bit offset)
2
11
5.5:1
copy-2-byte (6-bit len, 16-bit offset)
3
64
21.3:1
copy-4-byte (6-bit len, 32-bit offset)
5
64
12.8:1
Under Base's MAX_GOSSIP_SIZE = 10 MB cap, per-message decompressed size is bounded at ~213 MB. This is the load-bearing constant for the rest of the report.
Per-decompression cost
Single-process measurement on x86_64 Linux (release build):
Each gossip message processed through one sink costs the receiving peer ~0.18 s CPU + 213 MB peak RSS. With both sinks routed (sink-1 dedup miss followed by sink-2 handler), a single delivered message is ~0.36 s CPU + 213 MB peak.
Attacker reachability
libp2p gossipsub admits any peer that completes the standard noise + yamux + identify handshake. No Base-specific credentials, no bearer tokens, no IP allowlist. The attacker reaches the gossip mesh exactly as any honest follower does. Discoverable via code review alone; not "compromising Base-operated infrastructure."
Impact Details
Attack profile against Base's recommended hardware
Base's node-operator performance-tuning page recommends 32 GB RAM, 8+ vCPU.
The CPU-saturation threshold for "≥30 % resource consumption" on 8 cores = 2.4 cores busy.
Per-message CPU cost
0.18 s × 2 sinks = 0.36 cores-second per delivered message
Per-publish CPU cost on each receiver (4 topics, salted)
4 × 0.36 = 1.44 cores-second per attacker round per receiver
Threshold publishes/sec to saturate 30 % of the gossipsub task
2.4 / 1.44 ≈ 1.67 publishes/sec
Attacker uplink (mesh fan-out: mesh_D × topics × publishes/s × msg_size)
1.67 × 4 × 8 × 9.98 MB ≈ 534 MB/s ≈ 4.3 Gbps
Single attacker, ~4 Gbps uplink, every in-mesh peer simultaneously pays ≥30 % gossipsub-task CPU. The figure is for a single attacker meshed at one position; multiple sock-puppets at different mesh positions distribute the same total uplink without changing the per-receiver impact.
Memory side-effect: each delivered gossip message commits a 213 MB RSS spike that lives for the duration of the snappy decompression (~0.18 s) plus any handler-side validation. On a co-resident base-consensus + base-reth-node + RPC + flashblocks deployment (the standard configuration for Base followers), every spike forces the kernel to evict EL state-DB hot pages from page cache, slowing block sync. With swap disabled (universal for blockchain nodes — see "kernel/system configurations" below) the kernel cannot ease the pressure by paging.
Why this is amplification, not brute force
Per-byte ratios on Base's recommended 32 GB / 8-core hardware:
The discriminator between amplification and brute force is whether attacker resource cost is symmetric to victim cost. This finding's per-byte ratios are asymmetric by 1–3 orders of magnitude. Two further structural points:
A code-level fix exists and closes the entire amplification factor (5-line patch using a constant Base already defines). Pure brute-force floods can only be mitigated at the network edge.
Mesh fan-out decouples attacker uplink from victim count. A bandwidth flood scales linearly with victim count; this attack's uplink is bounded by
mesh_D × topics × stacking-rate × msg_size, independent of the number of in-mesh peers paying CPU + memory cost.
Kernel and system configurations that lower the threshold
Common production-server defaults that increase the bug's blast radius (no operator misconfiguration required):
Swap disabled / vm.swappiness=0
No swap relief on memory pressure → process competes for committed RSS. Universal on blockchain nodes (operators disable swap to avoid latency variance).
Co-resident base-consensus + base-reth-node + RPC + flashblocks
Per-process memory budget shrinks; gossipsub task's 213 MB spikes evict EL state-DB hot pages from kernel page cache. Universal in single-host deployments.
cgroup v2 with memory.swap.max=0
Container-level no-swap. Docker default for mem_limit.
vm.overcommit_memory=2 (strict accounting)
Allocations rejected before they can be paged out → fast-fail OOM at lower RSS.
systemd-oomd enabled
Proactive OOM kill at lower memory-pressure thresholds. Increasingly common on Ubuntu/Fedora servers.
Under the standard "swap=0 + co-resident services" production deployment, every gossip message processed forces a memory-pressure shock to the EL state cache, slowing block sync below sustainable rate.
Severity classification
Per Base's bounty severity table:
MEDIUM: "Increasing network processing node resource consumption by at least 30 % without brute force actions, compared to the preceding 24 hours."
This is the load-bearing classification. On 32 GB / 8-core followers, a single attacker at ~4 Gbps causes ≥30 % gossipsub-task CPU saturation on every in-mesh peer per the derivation above.
Two subordinate points:
The bug also satisfies the alternative MEDIUM criterion "Shutdown of ≥30 % of network processing nodes without brute force actions" on lower-RAM victims (cgroup containers, low-spec follower deployments). Empirically reproduced at 1 GB cgroup with explicit
container oomevents; see PoC.The finding does not rise to HIGH: no direct ≥500 % block-production delay (Base's sequencer is centralized; gossip layer is not on the L2 block-production critical path), no chain-split, no RPC-API crash on programs with ≥25 % market cap.
References
Source-level evidence (Base v0.8.0-rc.24, commit 819ea30)
crates/consensus/gossip/src/config.rs:15—pub const MAX_GOSSIP_SIZE: usize = 10 * (1 << 20);crates/consensus/gossip/src/config.rs:31—pub const DEFAULT_MESH_D: usize = 8;crates/consensus/gossip/src/config.rs:88—.max_transmit_size(MAX_GOSSIP_SIZE)(compressed cap binding)crates/consensus/gossip/src/config.rs:104-119—compute_message_id(Sink 1)crates/consensus/gossip/src/handler.rs:50-72—BlockHandler::handle(Sink 2 dispatcher)crates/consensus/gossip/src/handler.rs:79-87— accepted-topics matrix (topics()returns all four)crates/consensus/gossip/src/handler.rs:99-102— topic instances created inBlockHandler::newcrates/common/rpc-types-engine/src/envelope.rs:258, 302, 346, 402— fourdecode_vN::decompress_vecsites
Upstream evidence
snap-1.1.1/src/decompress.rs:105-110—decompress_vecallocatesvec![0; decompress_len(input)?]before validating payload completenesssnap-1.1.1/src/lib.rs:93—MAX_INPUT_SIZE = u32::MAXallows up to 4 GB declared decompressed sizelibp2p-gossipsub mesh-D fan-out: one publish reaches all in-mesh peers in O(log_D N) hops via mesh forwarding
Snappy format spec:
copy-2-byte len=64 offset=1yields 21.3:1 ratio per chained instructionBase hardware recommendations: https://docs.base.org/base-chain/node-operators/performance-tuning#hardware
Bug introduction
The vulnerable pattern was introduced at the initial Base Consensus port in commit feat(consensus): Base Consensus (#898) — the gossip config and envelope decoders were ported from op-node without a decompressed-size cap. Pattern unchanged through v0.8.0-rc.24. No fix commit observed in the rc.1–rc.24 range.
In-scope confirmation
crates/consensus/gossip/andcrates/common/rpc-types-engine/are insidecrates/, not in any out-of-scope directory.Reachable via libp2p gossip mesh, no Base-operated infrastructure compromise required.
Discoverable via code review alone — both sinks are 5–20 line public Rust functions.
Not present in the publicly-disclosed known-issues list.
Link to Proof of Concept
https://gist.github.com/gzeoneth/9c756cb5dcc09ebf29514bb2856faa4f
Proof of Concept
Single-process measurement —
snap-bomb-poc.rs. Demonstrates the snappy-level 21:1 ratio, 213 MB committed RSS, and 0.18 s CPU perdecompress_veccall. Run on Linux withcargo run --releasefollowed by/usr/bin/time -v target/release/snap-bomb-poc.
Multi-node end-to-end reproduction —
poc-dockerhttps://gist.github.com/gzeoneth/9c756cb5dcc09ebf29514bb2856faa4f A 4-container Docker Compose stack running the EXACT vulnerablecompute_message_idpattern + thedecode_vNsecond sink, demonstrating OOM kill of a 1 GB cgroup follower under realistic Base-shaped attacker config (3 sock-puppets × 4 topics × 2 publishes/s × 2 sinks). Captured 2026-04-25:
OOMKilled=false is normal post-restart docker behaviour; RestartCount=1 and the explicit container oom event are the load-bearing evidence.
The cgroup-OOM scenario demonstrates that the bug's per-message peak RSS (213 MB) is sufficient to OOM-kill containers below ~1 GB. The primary impact on production 32 GB hardware is the CPU + memory-pressure profile derived in "Impact Details" above, not direct OOM.
Reproduction commands
PoC adaptations explicitly disclosed
Two cosmetic adaptations were made to the PoC; neither affects the structural attack:
mesh_n=2/n_low=1/n_high=4/outbound_min=0(vs. Base's production 8/6/12). Required because libp2p-gossipsub validatesD_low * 2 ≤ Dand a 4-node mesh cannot satisfy 6/12. Mesh-D affects fan-out efficiency, not whether the bug exists. The 32 GB-hardware derivation in "Impact Details" uses Base's productionmesh_D=8.add_explicit_peer()is called after dial completes. In production Base, gossipsub naturally meshes within ~2 heartbeats once SUBSCRIBE control messages exchange.
The fix
The existing MAX_GOSSIP_SIZE = 10 MB constant is already imported at both vulnerable sites. snap::raw::decompress_len reads only the ≤5-byte varint header and performs no allocation, so it is safe to call before decompress_vec:
Apply in:
crates/consensus/gossip/src/config.rs:104-119(compute_message_id)crates/common/rpc-types-engine/src/envelope.rs:258, 302, 346, 402(all fourdecode_vN)
A NetworkPayloadEnvelope v4 carrying a full-size Isthmus block (30 M gas) is well under 10 MB SSZ-encoded, so 10 MB decompressed accommodates every legitimate payload. Using the same constant as the compressed cap forces effective ratio ≤ 1:1 (decompressed cannot exceed compressed cap), structurally eliminating the amplification factor regardless of future Snappy version changes.
Fix verification
Apply the patch above to vulnerable_message_id in poc-docker/src/main.rs and rebuild:
Victim RSS stays under ~50 MB regardless of attacker rate. RestartCount remains at 0. Attacker publish calls succeed at the wire level but receivers reject the bombs at message-id computation, so the RSS impact is zero.
Was this helpful?