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

74480 bc critical gossip snappy decompression missing max gossip size check lets any peer dos base consensus

Submitted on Apr 22nd 2026 at 21:07:41 UTC by @QED for Audit Comp | Base Azul

  • Report ID: #74480

  • Report Type: Blockchain/DLT

  • Report severity: Critical

  • Target: https://github.com/base/base/releases/tag/v0.8.0-rc.15

  • Impacts:

    • Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments

    • Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours

Description

Brief/Intro

base-consensus snappy-decompresses inbound gossip messages twice per packet without checking decoded size against MAX_GOSSIP_SIZE. A wire-valid 9.4 MiB packet decompresses to 200 MiB at each of two sites. Any anonymous libp2p peer can trigger it. Sustained attack from 4 unauthenticated peers halts the sequencer's block propagation 100% against a local devnet running the official Base sequencer stack.

Vulnerability Details

Invariant the code claims

// crates/consensus/gossip/src/config.rs:13-15
/// The maximum gossip size.
/// Limits the total size of gossip RPC containers as well as decompressed individual messages.  // <-- CLAIMED INVARIANT
pub const MAX_GOSSIP_SIZE: usize = 10 * (1 << 20);

The cap is enforced only on compressed wire bytes (.max_transmit_size(MAX_GOSSIP_SIZE)), never on decoded size.

Root cause #1 — compute_message_id decompresses every inbound message

Called before topic filtering: every gossipsub message on any topic the node hears about pays the decompression cost.

Root cause #2 — decode_v* decompresses block-topic messages again

decode_v2/v3/v4 at envelope.rs:299, :343, :399 follow the same pattern.

Why the signer check doesn't gate the allocation

No ingress-level gate

Reachability

crates/common/chains/src/config.rs:254-257 hardcodes two Sepolia bootnodes. Both accept anonymous libp2p on 9222/tcp; probe against live in-scope hosts:

Fresh secp256k1 keypair, MessageAuthenticity::Anonymous, 100 KiB benign payload (not the bomb). TCP + Noise + yamux + gossipsub + topic-mesh admission all succeed. No credentials of any kind required.

Impact Details

Network not being able to confirm new transactions (total network shutdown).

Each inbound packet costs the sequencer's single-threaded gossip task ~130 ms of CPU and ~464 MiB of transient heap (9.4 MiB wire → 200 MiB decoded, double-decompressed). 4 anonymous peers pushing ~75 Mbps aggregate (8 bombs/s, unique per message) saturate that task continuously, so the sequencer's own publish() — which runs on the same task — never gets a turn. Result: block-v4 propagation drops from 0.500 blocks/s to 0.000 blocks/s for the full attack window.

Measured against the real sequencer stack

Run measure-rate against DevnetBuilder::new().build() (real L1 anvil + real base-reth-node EL + real in-process base-consensus-node in sequencer mode):

Attacker capability: 4 anonymous libp2p peers, each publishing a 200 MiB-decoded / 9.4 MiB-compressed snappy bomb every 500 ms with a per-message nonce byte. Aggregate wire ~75 Mbps. No credentials.

Per-packet cost at the victim (measured in e2e-localnet):

Gossipsub's behaviour task is single-threaded, so sustained delivery saturates the sequencer's swarm-task CPU. The sequencer's own publish() runs on that same task, so its block broadcast waits behind the attacker backlog. Measurement shows the backlog fully starves block propagation.

Prior art

Ethereum consensus-specs' p2p-interface mandates a decompress_len cap before allocation. Prysm, Lighthouse, Teku, Nimbus, and paradigmxyz/reth (RLPx path) all implement it. base-consensus is the only Rust-Ethereum snappy consumer on this review surface that skips it. Direct class precedent: CVE-2023-34455 (snappy-java) and Lodestar GHSA-53rv-hcvm-rpp9.

References

Task commit de349fc9e8bf61531ce36ca57572345b03b2b097:

  • crates/consensus/gossip/src/config.rs:13-15MAX_GOSSIP_SIZE + claimed invariant

  • crates/consensus/gossip/src/config.rs:75-101 — builder wiring only max_transmit_size

  • crates/consensus/gossip/src/config.rs:104-122compute_message_id (ROOT CAUSE #1)

  • crates/consensus/gossip/src/handler.rs:49-60 — dispatch to decode_v*

  • crates/common/rpc-types-engine/src/envelope.rs:255,299,343,399decode_v1..v4 (ROOT CAUSE #2)

  • crates/consensus/gossip/src/block_validity.rs:108-115,218-230 — signer check runs post-decompress

  • crates/consensus/gossip/src/behaviour.rs:54MessageAuthenticity::Anonymous

  • crates/client/cli/src/p2p.rs:93-990.0.0.0:9222, banning off

  • crates/common/chains/src/config.rs:254-257 — Sepolia bootnodes

  • snap-1.1.1/src/lib.rs:93 — snap's MAX_INPUT_SIZE = u32::MAX

External:

  • Eth consensus-specs p2p-interface: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md

  • Snappy format spec: https://github.com/google/snappy/blob/main/format_description.txt

  • Lodestar GHSA-53rv-hcvm-rpp9: https://github.com/ChainSafe/lodestar/security/advisories/GHSA-53rv-hcvm-rpp9

  • reth RLPx size-check reference: https://github.com/paradigmxyz/reth/blob/main/crates/net/eth-wire/src/p2pstream.rs

https://gist.github.com/x-qedaudit/cfce5f46348b92ced0ca8220fe13bf9d

Proof of Concept

Two binaries in a single Cargo crate.

Shared Cargo.toml

e2e-localnet — per-packet heap measurement

Real-wire demo against a victim wired with Base's production default_config(). Victim subscribes to all four block topics and dispatches inbound messages to NetworkPayloadEnvelope::decode_v{1..4} exactly like BlockHandler::handle. Sender publishes one bomb over real libp2p. Tracking GlobalAlloc reports peak heap.

~49× wire-to-heap amplification. 2 × 200 MiB = both decompression sites fire before SSZ rejection.

measure-rate — block-propagation halt against the official sequencer

Spins up the real devnet (L1 anvil + base-reth-node EL + in-process base-consensus sequencer), connects our observer to the sequencer's libp2p port, runs an attacker pool, and measures inbound block-v4 arrival rate.

Each attacker uses Base's own default_config() and a per-message seed-byte nonce so each bomb has a unique message-id and bypasses gossipsub's duplicate cache. All 388 bombs delivered to the sequencer; zero publish errors; the sequencer's block-v4 broadcast went to zero for the full 45 s window.

Full source including the inline bomb generator, tracking allocator, and event-loop handling: gist.

Mitigation

Call snap::raw::decompress_len first — it parses only the varu32 preamble and never allocates — and reject when the declared length exceeds MAX_GOSSIP_SIZE. Apply at both decompression sites.

Canonical fix: paradigmxyz/reth (same snap crate, same input class)

Port to Base

compute_message_id (crates/consensus/gossip/src/config.rs:104):

NetworkPayloadEnvelope::decode_v{1..4} (crates/common/rpc-types-engine/src/envelope.rs:255,299,343,399):

Regression tests

  • Snappy blob with 1 MiB compressed / 1 GiB declared decoded must be rejected by compute_message_id and every decode_v* with no 1 GiB allocation.

  • measure-rate against a patched sequencer reports drop ≤ 5%.

Was this helpful?