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

75469 bc critical gossip payload decoder allocates unbounded snappy output

Submitted on Apr 29th 2026 at 10:37:47 UTC by @y4y for Audit Comp | Base Azul

  • Report ID: #75469

  • Report Type: Blockchain/DLT

  • Report severity: Critical

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

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

Description

Brief/Intro

Incoming unsafe-block gossip messages are Snappy-decompressed before any decoded-size bound is enforced. The gossipsub message-id callback decompresses the attacker-supplied packet once, and the block envelope decode path decompresses it again. Both use snap::raw::Decoder::decompress_vec(...), which allocates a vector sized from the attacker-controlled Snappy decoded-length header before signature checks, SSZ checks, or block-validity checks.

This allows a remote peer to send a comparatively small packet and force large memory allocations on the victim node before the message is rejected. The attack is off-chain, requires no gas, and is viable against any node that participates in publicly reachable consensus gossip. Repeated packets also consume CPU, because the large Snappy decode and memory-touch path runs again on each packet even after allocator reuse limits further RSS growth.

Vulnerability Details

The gossipsub message-id callback decompresses the full packet body before computing the deduplication hash:

// base/crates/consensus/gossip/src/config.rs
fn compute_message_id(msg: &Message) -> MessageId {
    let mut decoder = Decoder::new();
    let id = decoder.decompress_vec(&msg.data).map_or_else(
        |_| {
            ...
        },
        |data| {
            let domain_valid_snappy: Vec<u8> = vec![0x1, 0x0, 0x0, 0x0];
            sha256([domain_valid_snappy.as_slice(), data.as_slice()].concat().as_slice())[..20]
                .to_vec()
        },
    );

    MessageId(id)
}

Then the receive path decompresses the same packet again while decoding the network payload envelope:

Each versioned decode helper calls decompress_vec(...) immediately:

The underlying Snappy library allocates the output buffer directly from the decoded-length header before validating the compressed body:

So the effective flow is:

Concrete verified example from the primary PoC:

  • compressed packet size: 9,443,332 bytes

  • declared decompressed size: 201,326,592 bytes (192 MiB)

  • a live local attacker peer connected to a live local victim GossipDriver, observed the victim subscription, and published the packet over a real local libp2p/gossipsub connection

  • the live local p2p packet-flow PoC measured about 249,648 KiB (~244 MiB) of additional victim RSS from one packet received over the actual listener

  • the secondary RSS-stress PoC measured about 209,104 KiB (~204 MiB) of additional RSS from one packet on the direct receive path

  • thirty-two concurrent packets caused about 12,064,928 KiB (~11.5 GiB) of additional RSS

Attacker-side traffic for that 32-packet burst was only:

So a few hundred MiB of attacker traffic forced more than eleven GiB of victim memory pressure before rejection.

Pre-conditions

  • The target node participates in consensus gossip and accepts inbound p2p peers.

  • The attacker can establish a p2p connection and send one or more gossip packets.

  • The packet is routed into the unsafe-block gossip receive path.

This does not require malformed on-chain state, trusted roles, operator mistakes, or gas expenditure.

This reachability assumption is realistic for publicly peered nodes:

  • the default p2p bind is 0.0.0.0 with external listener ports in base/crates/client/cli/src/p2p.rs;

  • the official node-operator documentation instructs operators to keep the peer-discovery ports accessible for sync;

  • the checked-in mainnet and sepolia chain configs include public bootnodes;

  • on April 29, 2026, direct TCP connection attempts to multiple bootnode endpoints from the checked-in config succeeded, including 3.231.138.188:9200, 184.72.129.189:9200, 18.210.176.114:9200, and 107.21.251.55:9200.

This does not mean every production node is publicly reachable, because operators can still firewall or private-peer their p2p layer. It does mean the attack surface is normal and intended for public consensus participants, not an artificial lab-only posture.

Impact Details

This is a low-cost off-chain resource exhaustion issue.

The attacker pays only for:

  • a reachable p2p connection,

  • bandwidth for the crafted packets,

  • ordinary host/network overhead.

No transaction needs to be mined and no gas is paid.

The victim, however, allocates large decode buffers before rejecting the message. That creates a clean asymmetric DoS condition:

  • one ~9 MiB packet can add ~204 MiB of RSS,

  • 8 concurrent packets added ~3.0 GiB,

  • 16 concurrent packets added ~5.9 GiB,

  • 32 concurrent packets added ~11.5 GiB.

This maps cleanly to the Medium impact bucket for materially increasing node resource consumption and, on smaller memory budgets, can plausibly lead to process death or cgroup/OOM-killer intervention.

The same receive path also creates sustained CPU pressure under a paced stream. In the live p2p PoC, increasing M13_MESSAGE_COUNT from 1 to 10 drove the victim process to full CPU utilization while the attacker kept publishing new packets.

References

  • base/crates/consensus/gossip/src/config.rs:103-119

  • base/crates/consensus/gossip/src/handler.rs:48-75

  • base/crates/common/rpc-types-engine/src/envelope.rs:255-258,299-302,343-346,399-402

  • snap-1.1.1/src/decompress.rs:20-29,103-109

Proof of Concept

First PoC

The PoC should be added to base/crates/consensus/gossip/examples/m13_gossip_live_p2p_poc.rs:

A secondary PoC can also be run, and create the file m13_gossip_snappy_rss_poc.rs under the same directory:

To run the first PoC:

Expected output shape:

The success conditions are:

  • the victim prints a real local dial_addr=/ip4/127.0.0.1/tcp/.../p2p/...;

  • the attacker prints connected to victim;

  • the attacker prints observed victim subscription;

  • the attacker prints one or more published count=N lines;

  • the victim prints matching MESSAGE count=N lines up to the configured M13_MESSAGE_COUNT;

  • the victim prints acceptance=Reject only after processing the packet;

  • the victim prints a non-trivial delta_rss_kb.

Second PoC

A secondary PoC can also be run, and create the file m13_gossip_snappy_rss_poc.rs under the same directory:

To run the second PoC:

The example prints these lines:

The important conditions are:

  • compressed_bytes is below MAX_GOSSIP_SIZE = 10485760

  • declared_decompressed_bytes=201326592 shows that the packet forces a 192 MiB decode buffer

  • acceptance=Reject shows the packet is rejected only after the large allocation path has already run

  • delta_rss_kb=209104 shows that one packet alone drove about 204 MiB of victim RSS growth

This runs multiple workers inside the same victim process so that several large Snappy decodes overlap in time.

The important fields are:

  • concurrency=32 total_messages=32

  • compressed_bytes=9443332

  • declared_decompressed_bytes=201326592

  • peak_rss_kb=12278576

  • delta_rss_kb=12064928

That verified run drove about 11.5 GiB of additional RSS from a burst of thirty-two ~9.0 MiB packets, all rejected only after the vulnerable decode path ran.

Attack cost

The attack is off-chain and does not require gas.

Single-packet cost in the primary visual PoC:

  • attacker sends one 9,443,332 byte packet

  • packet stays below MAX_GOSSIP_SIZE = 10,485,760 bytes

  • packet is rejected only after the victim allocates and touches the large decode buffers

Verified 32-packet burst cost:

  • attacker traffic: 32 * 9,443,332 = 302,186,624 bytes (~288 MiB)

  • victim additional RSS: 12,064,928 KiB (~11.5 GiB)

So the attacker-side cost is mainly:

  • one reachable p2p connection

  • bandwidth for the crafted packets

  • ordinary host/network overhead

There is no requirement to pay on-chain fees or to get a transaction mined.

Verified outputs

Verified run: 64 MiB declared output

Verified run: 192 MiB declared output

Verified run: 192 MiB declared output, 3 packets

Verified run: 192 MiB declared output, 8 concurrent packets

Verified run: 192 MiB declared output, 16 concurrent packets

Verified run: 192 MiB declared output, 32 concurrent packets

Was this helpful?