74556 bc critical base azul cl unbounded snappy decompression in gossipsub message id fn allows a single unauthenticated p2p message to oom kill every reachable consensus node halting the c
Submitted on Apr 23rd 2026 at 12:30:12 UTC by @Venator for Audit Comp | Base Azul
Report ID: #74556
Report Type: Blockchain/DLT
Report severity: Critical
Target: https://github.com/base/base/releases/tag/v0.8.0-rc.15
Impacts:
Unintended chain split (network partition)
Unintended permanent chain split requiring hard fork (network partition requiring hard fork)
Description
1. Summary
The Base consensus-layer gossipsub stack registers a custom message_id_fn (compute_message_id) that decompresses every incoming gossip message using snap::raw::Decoder::decompress_vec before any signature verification, application validation, or peer scoring. The snap crate pre-allocates a Vec<u8> whose size is read directly from the snappy frame header — an attacker-controlled field — with the only bound being u32::MAX (~4 GiB).
An attacker who establishes a libp2p connection to any Base CL peer (no authentication beyond noise handshake), joins the gossipsub mesh on a /optimism/{chain_id}/N/blocks topic, and publishes a single message containing a crafted snappy frame can force every receiving peer to attempt a ~4 GiB heap allocation. On resource-constrained nodes the allocation panics or triggers the Linux OOM killer, terminating the process. On large-memory nodes, repeated messages cause progressive exhaustion.
The attack requires:
One libp2p connection (TCP + noise + yamux, no identity verification).
One gossipsub PUBLISH of ≤10 MiB (within
MAX_GOSSIP_SIZE).Zero knowledge of the unsafe block signer key.
Zero on-chain transactions.
The fix is a single pre-check: verify snap::raw::decompress_len(input) <= MAX_GOSSIP_SIZE before calling decompress_vec.
2. Vulnerable source
2.1 Primary site — compute_message_id
crates/consensus/gossip/src/config.rs:104-122:
Registered as the gossipsub message_id_fn at config.rs:93:
2.2 Snap crate internals
snap-1.1.1/src/decompress.rs:105-107:
snap-1.1.1/src/lib.rs:93:
decompress_len reads a varint from the snappy frame header and rejects values above MAX_INPUT_SIZE. Values up to u32::MAX (~4 GiB) pass the check, causing vec![0; 4_294_967_295] — a 4 GiB zero-filled allocation — before any frame data is even inspected.
2.3 Secondary sites — NetworkPayloadEnvelope::decode_v*
crates/common/rpc-types-engine/src/envelope.rs lines 258, 302, 346, 402 (decode_v1 through decode_v4):
These are called from BlockHandler::handle (crates/consensus/gossip/src/handler.rs:51-62) after the message ID is computed. They constitute a second decompression of the same attacker-controlled msg.data with the same unbounded allocation. If the compute_message_id bomb is patched but these are not, the attack survives at the handler level (still pre-signature).
2.4 Integration path
crates/consensus/service/src/actors/network/config.rs:93:
default_config() calls default_config_builder().build() which sets .message_id_fn(compute_message_id). This confirms the vulnerable code path is live in the base-consensus binary — the main CL daemon.
3. Attack mechanics
3.1 Crafting the bomb frame
A snappy frame consists of a varint-encoded decompressed length followed by compressed chunks. The attacker needs only a valid varint header declaring a large output size; the actual compressed payload can be minimal (or even malformed — the allocation happens before decompression begins).
Minimal bomb payload (Python):
Output:
The 7-byte payload passes max_transmit_size (10 MiB) trivially. The snap crate's decompress_len reads the varint, sees 4,294,967,295 <= u32::MAX, returns Ok(4_294_967_295). decompress_vec then calls vec![0; 4_294_967_295].
3.2 Delivery via gossipsub
3.3 Targeting the sequencer
The Base sequencer runs a CL instance (base-consensus) that gossips unsafe blocks to the network. It must listen for incoming libp2p connections to fulfill its role as block publisher. Its multiaddr is either:
Published in discv5 ENRs (discoverable by any discv5 participant).
Known via Base's public infrastructure documentation or network scanning.
Killing the sequencer's CL → the sequencer's EL receives no engine_forkchoiceUpdated calls → no new blocks → chain halt.
4. Proof of concept — unit-level
The following Rust test demonstrates the allocation behavior without requiring a full libp2p network:
A full network-level PoC would:
Spin up a
base-consensusdevnet node.Connect a libp2p client to the node's gossip port.
Publish the 7-byte bomb on the blocks topic.
Observe the
base-consensusprocess being killed (exit code 137/SIGKILL from OOM killer, or abort fromhandle_alloc_error).
5. Exploitation timeline
6. Severity argument
6.1 Primary impact classification
Primary ISIS impact: "Unintended permanent chain halt" — Critical.
A single unauthenticated P2P message (7 bytes payload, ~100 bytes on wire) terminates the sequencer's CL process. The chain halts immediately. The attack is repeatable after restart with zero cost (no on-chain transactions, no gas, no stake). No configuration change mitigates it — the vulnerable code path (message_id_fn) is hardcoded. Only a code patch resolves the issue.
Alternative ISIS impact (if chain halt is categorized as temporary): "Unintended chain split (network partition)" — Critical. Selectively killing CL nodes creates a partition between nodes that have received the latest unsafe blocks and those that haven't.
6.2 Program DoS severity gate
The Immunefi Base Azul program specifies:
"In the case that the denial of service requires restarting the program to reset, this must be achieved by a single small network request or database entry."
This attack satisfies that criterion precisely:
Single request: one gossipsub PUBLISH message.
Small: 7 bytes payload (100 bytes on wire).
Requires restart to recover: the process is terminated; restart is the only recovery.
Persistent after restart: attacker re-sends immediately; crash loop.
6.3 On-chain effects
Sequencer CL
Chain halt — no new L2 blocks produced. All L2 dApps, bridges, and users affected.
Challenger CL
Cannot monitor or challenge invalid proposals. Invalid state may finalize via L1 bridge → fund theft risk.
Proposer CL
Cannot submit new proposals. Withdrawal proofs stall.
Full-node CL
RPC unavailable for dApps. Users cannot query state or submit transactions.
6.4 Attacker cost
libp2p client
Open-source, trivial to implement
Network bandwidth
~100 bytes per kill
Peer discovery
discv5 query (public, free)
On-chain cost
Zero
Repeat cost
Zero (new peer ID per connection)
6.5 Amplification factors
Memory amplification: 7 bytes → 4 GiB allocation = 613 million ×.
Mesh fan-out: gossipsub forwards to
mesh_D = 8peers. One publish → up to 8 nodes hit simultaneously.Pre-authentication:
compute_message_idruns before any signature or signer check. Attacker needs zero knowledge of the unsafe block signer key.
6.6 Why peer scoring does not mitigate
libp2p-gossipsub scores peers negatively when the application returns MessageAcceptance::Reject. However:
compute_message_idruns beforeMessageAcceptanceis returned — the damage (allocation, OOM) occurs before the peer can be scored.Even if the node survived and scored the peer negatively, the attacker can reconnect with a fresh libp2p identity (new keypair). Peer identity is not authenticated against any on-chain registry.
The
ValidationMode::Nonesetting (config.rs:91) means gossipsub itself performs no publisher signature verification.
7. Fix
7.1 Primary fix — bound decompress_vec input in compute_message_id
decompress_len reads only the varint header (≤10 bytes, O(1), no allocation). The guard rejects any frame declaring more than 10 MiB decompressed output — matching the already-enforced max_transmit_size.
7.2 Secondary fix — bound decode_v* in NetworkPayloadEnvelope
Apply the same decompress_len guard before each decompress_vec call in crates/common/rpc-types-engine/src/envelope.rs (lines 258, 302, 346, 402):
7.3 Hardening — crate-level wrapper
Consider introducing a bounded_decompress_vec utility in base-common that wraps snap::raw::Decoder::decompress_vec with a mandatory max_output_size parameter, making it impossible to introduce this class of bug in future call sites:
8. Exact code references
crates/consensus/gossip/src/config.rs
104-122
compute_message_id — primary vulnerable function
crates/consensus/gossip/src/config.rs
93
.message_id_fn(compute_message_id) — registration as gossipsub ID function
crates/consensus/gossip/src/config.rs
15
MAX_GOSSIP_SIZE = 10 * (1 << 20) — wire-level size limit (not enforced on decompressed output)
crates/consensus/gossip/src/config.rs
88
.max_transmit_size(MAX_GOSSIP_SIZE) — libp2p transmit limit (compressed only)
crates/consensus/gossip/src/config.rs
91
.validation_mode(ValidationMode::None) — no gossipsub-level publisher auth
crates/common/rpc-types-engine/src/envelope.rs
258
decode_v1 — secondary vulnerable decompress_vec
crates/common/rpc-types-engine/src/envelope.rs
302
decode_v2 — secondary vulnerable decompress_vec
crates/common/rpc-types-engine/src/envelope.rs
346
decode_v3 — secondary vulnerable decompress_vec
crates/common/rpc-types-engine/src/envelope.rs
402
decode_v4 — secondary vulnerable decompress_vec
crates/consensus/gossip/src/handler.rs
50-77
BlockHandler::handle — calls decode_v* post-message-ID
crates/consensus/gossip/src/driver.rs
332-372
handle_gossipsub_event — receives pre-computed message_id from libp2p
crates/consensus/gossip/src/driver.rs
184
self.swarm.listen_on(self.addr.clone()) — public listener
crates/consensus/service/src/actors/network/config.rs
93
gossip_config: base_consensus_gossip::default_config() — integration in CL binary
snap-1.1.1/src/decompress.rs
105-107
decompress_vec — pre-allocates vec![0; decompress_len(input)?]
snap-1.1.1/src/decompress.rs
363-373
Header::read — parses varint, checks <= MAX_INPUT_SIZE
snap-1.1.1/src/lib.rs
93
MAX_INPUT_SIZE = u32::MAX — ~4 GiB ceiling
9. Related surfaces
discv5 discovery: The
crates/consensus/discmodule handles UDP-based peer discovery. If any snappy or RLP decoding in the discovery path has similar unbounded-allocation patterns, it would be exploitable without even establishing a TCP connection. Not analyzed in this report.base_insertValidatedTransactionRPC (crates/execution/txpool/src/builder/rpc.rs:72): usesRecovered::new_uncheckedto trust caller-supplied sender addresses. If thebaseRPC namespace is exposed on public HTTP/WS, this enables mempool censorship. Separate finding, distinct root cause.
10. Disclosure
Discovered: 2026-04-22, during the Immunefi Audit Comp | Base Azul.
Disclosure: via the Immunefi Audit Comp | Base Azul program.
PoC: Unit-level (§4). Network-level PoC deferred to avoid accidental impact on public infrastructure; the unit test proves the allocation path and amplification ratio.
Proof of Concept
The following Rust test demonstrates the allocation behavior without requiring a full libp2p network:
A full network-level PoC would:
Spin up a
base-consensusdevnet node.Connect a libp2p client to the node's gossip port.
Publish the 7-byte bomb on the blocks topic.
Observe the
base-consensusprocess being killed (exit code 137/SIGKILL from OOM killer, or abort fromhandle_alloc_error).
Was this helpful?