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

74657 bc critical remote node dos via unbounded snappy decompression in gossip message processing

Submitted on Apr 24th 2026 at 04:14:49 UTC by @nord0x for Audit Comp | Base Azul

  • Report ID: #74657

  • Report Type: Blockchain/DLT

  • Report severity: Critical

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

  • 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

The compute_message_id function in Base's gossip layer (crates/consensus/gossip/src/config.rs:104) calls snap::raw::Decoder::decompress_vec() on every inbound P2P gossip message before block-level validation, before signature verification (disabled via ValidationMode::None), and before duplicate cache insertion. Because Base uses MessageAuthenticity::Anonymous, any peer that completes a Noise handshake and joins the gossip mesh can send a single 6 MiB message (within the 10 MiB MAX_GOSSIP_SIZE limit) that decompresses to 128 MiB, causing a +276 MiB peak physical memory spike (verified via VmHWM). Under standard container memory limits (256 MiB), this triggers an immediate OOM-kill by the Linux kernel.

An attacker can sequentially crash any reachable Base node at a measured rate of ~4 seconds per node — connect (2 ms), mesh formation (1.5 s), publish (instant), keepalive (2 s). With public discv5 peer discovery providing a complete node list and a fresh Ed25519 identity costing microseconds (bypassing per-peer scoring), a single attacker can crash 30% of a 200-node network in under 4 minutes.

Vulnerability Details

Root Cause

In crates/consensus/gossip/src/config.rs, the gossipsub config registers a message ID function at line 93:

The compute_message_id function (lines 104-122) decompresses every inbound message using snappy, regardless of claimed decompressed size:

Why This Is Exploitable

  1. No size pre-check. The snap crate's decompress_vec() reads the snappy varint header to determine decompressed size, then calls vec![0u8; n] to allocate. There is no check on n before allocation. For valid-snappy payloads, the vec![0; n] zeroes every byte, committing physical pages (not just virtual reservation).

  2. Pre-validation execution. In the libp2p-gossipsub 0.49.4 pipeline, message_id_fn runs before block_valid (the application handler), before report_message_validation_result, and before duplicate cache insertion. Specifically, in behaviour.rs:1792, the message ID is computed immediately after the data transform, before the message is passed to the application's validation handler.

  3. No authentication. ValidationMode::None disables gossipsub's built-in signature/author validation. MessageAuthenticity::Anonymous means messages carry no source PeerId. Any peer that completes a libp2p Noise handshake can publish to the mesh.

  4. No effective rate limiting. Base's ConnectionGater only restricts outbound dials (can_dial). There are no inbound connection limits. The libp2p swarm has no ConnectionLimits configured (builder.rs:215 — only with_idle_connection_timeout is set). An attacker can connect inbound without restriction.

  5. Peer scoring bypass. Base enables PeerScoreLevel::Light by default (client/cli/src/p2p.rs:156). The invalid_message_deliveries_weight is -140.4475 and the graylist_threshold is -40.0 — meaning one invalid message drops a peer below graylist. However, graylist only blocks future messages from that peer identity. The first bomb always lands. Generating a new Ed25519 keypair costs microseconds, giving the attacker a fresh identity with a clean score for each target.

Attack Model

This is a sequential per-node attack, not a mesh propagation attack. The bomb does not propagate because:

  • validate_messages() is set (config.rs:92) — gossipsub holds messages until the application calls report_message_validation_result(Accept).

  • The bomb payload fails NetworkPayloadEnvelope::decode_vN → returns MessageAcceptance::Reject → gossipsub does not forward.

  • If the bomb triggers OOM-kill, the node dies before forwarding can occur.

Instead, the attacker connects to each target node individually and publishes the bomb directly. The attack cycle is:

Phase
Time (measured)

Ed25519 keypair generation

<1 ms

TCP + Noise handshake

2.3 ms

Mesh formation (3 heartbeats × 500 ms)

1.5 s

Bomb publish

<1 ms

Delivery keepalive

2 s

Total per node

~3.9 s

Network-Scale Impact (Demonstrated)

Multi-node OOM-kill test: Three separate Base gossip nodes, each running in its own 256 MiB cgroup (simulating standard k8s/Docker container limits), were attacked sequentially from a single attacker process:

Attack economics at scale:

Network Size
25% Crash
30% Crash
100% Crash

100 nodes

1.6 min

2.0 min

6.5 min

200 nodes

3.3 min

3.9 min

13 min

500 nodes

8.1 min

9.8 min

33 min

Node discovery is trivial: Base uses discv5 (public DHT). The BootStore tracks up to 2,048 peers (crates/consensus/peers/src/store.rs:13). All peers are enumerable by participating in the DHT.

The attack requires no credentials, no tokens, no stake, and no knowledge of the target beyond its gossip port (discoverable via discv5). The attacker binary is ~100 lines of Rust using Base's own default_config_builder().

Secondary Decompression Paths

The same unbounded decompression exists in the envelope decode functions called by the BlockHandler after compute_message_id:

  • NetworkPayloadEnvelope::decode_v1 (crates/common/rpc-types-engine/src/envelope.rs) — decoder.decompress_vec(data)

  • NetworkPayloadEnvelope::decode_v2 — same

  • NetworkPayloadEnvelope::decode_v3 — same

  • NetworkPayloadEnvelope::decode_v4 — same

These provide a secondary decompression amplification: even if compute_message_id is patched, the handler's decode step also decompresses without size checks. Both paths must be fixed.

Impact

Severity: High

Impact category: Shutdown of greater than or equal to 30% of network processing nodes without brute force actions.

Justification: A single attacker crashes any reachable containerized Base node in ~4 seconds with one 6 MiB message, no authentication, and a trivially rotatable identity. At 916 nodes/hour sequential throughput, 30% of a 200-node network (60 nodes) is crashable in 3.9 minutes. This is automated sequential exploitation — not computational brute force (no hash cracking, key guessing, or entropy search). All nodes are discoverable via public discv5 DHT.

Concrete impact:

  • Any containerized Base node (k8s, Docker with standard memory limits) is killed by a single gossip message. On bare-metal hosts without memory limits, the attack causes a transient +276 MiB memory spike that is freed after decompression; repeated attacks can degrade performance but do not crash the node.

  • Nodes restart via orchestrator (k8s, systemd) — typical 5-30 second restart. During restart, the node is offline and cannot process blocks or serve peers.

  • Sustained attack keeps nodes oscillating: up → crash → restart → crash. Attacker re-dials after each restart.

  • Chain liveness degrades as sequencer/verifier nodes go offline.

  • No on-chain cost to the attacker (pure p2p layer attack).

https://gist.github.com/drawrowfly/a5be22a414400df43e78ccb3721936b1

Proof of Concept

I've created convinient to execute POC just in case https://gist.github.com/drawrowfly/a5be22a414400df43e78ccb3721936b1

The PoC consists of two runnable Rust programs — a victim gossip node and an attacker — that demonstrate the complete attack over a real libp2p gossipsub connection. Both use Base's own default_config_builder(), the exact production gossipsub configuration.

Setup

Alternatively, run the automated script which performs all steps:

1

Start Victim Node

Output:

The victim uses Base's exact default_config_builder() with MessageAuthenticity::Anonymous and ValidationMode::None — identical to production.

2

Attack

The attacker:

  1. Generates a fresh Ed25519 identity (<1 ms)

  2. Connects to the victim via TCP + Noise handshake (2 ms)

  3. Joins the gossipsub mesh (waits 1.5 s for 3 heartbeats)

  4. Compresses 128 MiB of 0xAA bytes with snappy → 6,295,556 bytes (6 MiB), within the 10 MiB MAX_GOSSIP_SIZE

  5. Publishes the bomb via gossipsub::publish()

Attacker output:

3

Observe Memory Impact on Victim

Victim output after receiving the bomb:

A single gossip message caused a +276 MiB physical memory peak (VmHWM). The victim's compute_message_id() decompressed the 6 MiB payload into 128 MiB, writing every byte, committing physical pages.

4

OOM-Kill Demonstration

Start the victim inside a 256 MiB memory-limited cgroup (simulating a standard k8s/Docker container):

Result:

The victim was OOM-killed by the Linux kernel after a single gossip message.

5

Multi-Node Attack

Three separate victim processes, each in its own 256 MiB cgroup, attacked sequentially by a single attacker:

Result:

Summary

Test
Method
Result

Single-node memory spike

E2E gossip attack

+276 MiB VmHWM from 6 MiB message

OOM-kill (256 MiB cgroup)

E2E + cgroup

Exit 137, oom_kill=1, kernel confirmed

Multi-node attack

3 nodes, 3 cgroups

3/3 OOM-killed in 11.8s

Attack cycle time

Measured end-to-end

~4s per node (916 nodes/hour)

Add a size check before decompression in compute_message_id:

Apply the same size check to decode_v1/v2/v3/v4 in crates/common/rpc-types-engine/src/envelope.rs.

Was this helpful?