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

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

1

Peer

Attacker runs a libp2p 0.56 client. Dials the target Base CL node's publicly-advertised multiaddr (discoverable via discv5 or known for sequencer nodes). Completes noise handshake (no identity verification — ValidationMode::None at config.rs:91).

2

Subscribe

Joins the gossipsub mesh on topic /optimism/{chain_id}/3/blocks (v4 blocks, current active topic). libp2p-gossipsub accepts a new peer with neutral score into the mesh (up to mesh_n_high = 12 peers per topic, config.rs:37).

3

Publish

Sends a gossipsub PUBLISH RPC containing the 7-byte bomb payload.

4

Trigger

The receiving node's libp2p-gossipsub implementation calls compute_message_id(msg) to compute the dedup ID before emitting Event::Message to the application. This is intrinsic to gossipsub's duplicate detection — the ID must be computed to check the seen-message cache.

5

Allocation

decompress_vec allocates 4 GiB. On a node with < 4 GiB free heap, this triggers handle_alloc_error (Rust's default OOM handler) which aborts the process. On Linux with overcommit, the kernel may grant the virtual mapping; subsequent zero-fill page faults then trigger the OOM killer.

6

Propagation

If the target node has mesh peers, gossipsub may forward the message to them before the allocation completes (depending on the implementation's forwarding model). Each forwarding peer also calls compute_message_id and triggers the same allocation.

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:

  1. Spin up a base-consensus devnet node.

  2. Connect a libp2p client to the node's gossip port.

  3. Publish the 7-byte bomb on the blocks topic.

  4. Observe the base-consensus process being killed (exit code 137/SIGKILL from OOM killer, or abort from handle_alloc_error).

5. Exploitation timeline

1

T0

Attacker identifies the Base sequencer's CL libp2p multiaddr via discv5 discovery or public infrastructure records.

2

T1

Attacker dials the sequencer's CL. libp2p noise handshake completes (no identity verification). Attacker subscribes to /optimism/8453/3/blocks. Gossipsub grants mesh membership (neutral initial score).

3

T2

Attacker publishes a single gossipsub message containing the 7-byte snappy bomb. Total wire cost: ~100 bytes including gossipsub framing.

4

T3

Sequencer's CL calls compute_message_id(msg). decompress_vec attempts vec![0; 4_294_967_295]. Process aborts or is OOM-killed.

5

T4

Sequencer's CL is dead. EL stops receiving engine_forkchoiceUpdated. No new L2 blocks. Chain halted.

6

T5

Operator detects outage, restarts base-consensus. Attacker re-connects (new peer ID, fresh score) and re-sends the bomb. Process dies again in < 1 second. Persistent crash loop.

7

T6

Extended attack: Attacker targets additional CL nodes — challenger, proposer, full nodes. Challenger CL crash → dispute games cannot be challenged → invalid proposals may finalize after disputeGameFinalityDelay. Proposer CL crash → no new proposals submitted.

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

Target
On-chain effect

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

Resource
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 = 8 peers. One publish → up to 8 nodes hit simultaneously.

  • Pre-authentication: compute_message_id runs 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_id runs before MessageAcceptance is 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::None setting (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

File
Lines
Description

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

  • discv5 discovery: The crates/consensus/disc module 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_insertValidatedTransaction RPC (crates/execution/txpool/src/builder/rpc.rs:72): uses Recovered::new_unchecked to trust caller-supplied sender addresses. If the base RPC 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:

  1. Spin up a base-consensus devnet node.

  2. Connect a libp2p client to the node's gossip port.

  3. Publish the 7-byte bomb on the blocks topic.

  4. Observe the base-consensus process being killed (exit code 137/SIGKILL from OOM killer, or abort from handle_alloc_error).

Was this helpful?