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

76092 bc medium missing decompressed length validation in gossip compute message id enables remote oom crash

Submitted on May 2nd 2026 at 17:11:47 UTC by @InfiniteSec for Audit Comp | Base Azul

  • Report ID: #76092

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • 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

The compute_message_id function in Base's gossip configuration calls snap::raw::Decoder::decompress_vec on incoming gossip message data without first checking the declared decompressed length in the snappy varint header. An attacker who joins the gossipsub mesh can send a single gossip message with approximately 5 bytes of payload whose snappy header declares a decompressed size of approximately 4 GiB, causing the receiving node to attempt a 4 GiB memory allocation during message ID computation, before any application-layer validation occurs. In containerized deployments this causes process abort; in overcommit systems it causes severe memory pressure and node unresponsiveness.

Vulnerability Details

The vulnerability is in the compute_message_id function at crates/consensus/gossip/src/config.rs:104-122. This function is registered as the gossipsub message_id_fn at config.rs:93 and is invoked on every incoming gossip message to compute a deduplication ID.

fn compute_message_id(msg: &Message) -> MessageId {
    let mut decoder = Decoder::new();
    let id = decoder.decompress_vec(&msg.data).map_or_else(
        |_| {
            let domain_invalid_snappy: Vec<u8> = vec![0x0, 0x0, 0x0, 0x0];
            sha256([domain_invalid_snappy.as_slice(), msg.data.as_slice()].concat().as_slice())
                [..20].to_vec()
        },
        |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)
}

The snap crate's decompress_vec implementation first reads the snappy stream's varint header to obtain the declared decompressed length, then immediately allocates a buffer of that size via vec![0; decompress_len(input)?] (decompress.rs:105-106). The allocation occurs unconditionally before any actual decompression or body validation. The snap crate's MAX_INPUT_SIZE constant is u32::MAX (approximately 4 GiB), which provides no practical constraint against memory abuse since the Header::read function (decompress.rs:362-374) only checks that the declared length does not exceed this constant.

The gossip layer's max_transmit_size is set to MAX_GOSSIP_SIZE (10 MiB) at config.rs:88, but this only limits the compressed wire-frame size via GossipsubCodec (protocol.rs:138-139). A 5-byte payload consisting of a snappy varint header declaring approximately 4 GiB decompressed size with no valid compressed body is well under the 10 MiB wire limit. The amplification ratio is approximately 858,993,459x.

In the libp2p-gossipsub message processing pipeline, config.message_id(&message) is called in handle_received_message (behaviour.rs:1792) before message_is_valid (behaviour.rs:1821) and BlockHandler::handle (driver.rs:346). ValidationMode::None (config.rs:91) means no signature or source validation occurs at the codec layer. MessageAuthenticity::Anonymous (behaviour.rs:54) means IdentityTransform passes raw data through unchanged. The complete attack path flows from inbound TCP connection through gossipsub codec decode, handle_received_message, IdentityTransform, and into compute_message_id's decompress_vec call with no check on the declared decompressed size at any point.

In containerized environments with memory limits (the common deployment model for Base nodes), attempting to allocate approximately 4 GiB causes the Rust global allocator to call abort(), terminating the process. In overcommit systems, the allocation may succeed but cause severe memory pressure when the OS attempts to page in the zeroed memory. The map_or_else error handler in compute_message_id (config.rs:107-112) is only reached if decompress_vec returns Err, but if the allocation itself fails due to OOM, the process aborts before reaching that error handler.

Impact Details

This vulnerability falls under the Blockchain/DLT category and maps to the Immunefi v2.3 severity classification "Increasing network processing node resource consumption by at least 30% without brute force actions."

An attacker can force a target node to attempt allocating approximately 4 GiB of memory by sending a single gossip message with approximately 5 bytes of payload. Nodes are publicly dialable by design (docs/specs/pages/protocol/consensus/p2p.md:82), and the attacker only needs to establish a libp2p connection and subscribe to a valid blocks topic to join the gossipsub mesh. No privileged keys (JWT, sequencer key, batcher key) are required. The attack is trivially repeatable and extremely low cost. In containerized deployments this causes process termination; in overcommit systems repeated messages cause cumulative memory pressure and node unresponsiveness. The attacker can re-trigger on node restart, creating a persistent denial of service condition.

References

  • https://github.com/base/base/blob/v0.8.0-rc.24/crates/consensus/gossip/src/config.rs#L104-L122

  • https://github.com/base/base/blob/v0.8.0-rc.24/crates/consensus/gossip/src/config.rs#L93

  • https://github.com/base/base/blob/v0.8.0-rc.24/crates/consensus/gossip/src/config.rs#L88

  • https://github.com/base/base/blob/v0.8.0-rc.24/crates/consensus/gossip/src/config.rs#L91

  • https://github.com/base/base/blob/v0.8.0-rc.24/crates/consensus/gossip/src/behaviour.rs#L54

  • https://github.com/base/base/blob/v0.8.0-rc.24/crates/consensus/gossip/src/driver.rs#L185

https://gist.github.com/366f9d668bd1c14297edd450a75653a5

Proof of Concept

The following PoC contains 3 tests, placed as integration tests in the base-consensus-node (consensus service) crate alongside that crate's other security PoC tests. Test 1 proves the snappy varint header construction, pre-allocation behavior, and amplification ratio. Test 2 confirms the production gossip configuration has no pre-validation. Test 3 is an end-to-end attack proof: it uses NetworkBuilder to construct and start a full consensus-layer NetworkDriver (containing both a GossipDriver and a Discv5Driver, following exactly the same initialization path as a production consensus node) in a child process with RLIMIT_AS = 2 GiB to simulate containerized deployment, then the parent process acts as the attacker and sends a 5-byte snappy bomb (declaring approximately 4 GiB decompressed size) over a real P2P gossipsub connection. The victim node calls decompress_vec inside compute_message_id, triggering an approximately 4 GiB allocation that exceeds the 2 GiB RLIMIT_AS limit, and the Rust global allocator calls abort(), killing the victim process with SIGABRT (signal 6).

Setup

  1. Save the PoC file to crates/consensus/service/tests/actors/poc_snappy_decompression_bomb.rs

  2. Add mod poc_snappy_decompression_bomb; in crates/consensus/service/tests/actors/mod.rs

  3. Add libc.workspace = true and snap.workspace = true to the [dev-dependencies] section in crates/consensus/service/Cargo.toml. No other dependency changes are needed.

  4. Run:

Full PoC source

Execution output

Test 1 proves the core mechanism of the snappy decompression bomb: a 5-byte snappy varint header can declare approximately 4 GiB of decompressed size, and the snap crate fully trusts the declared value. decompress_vec executes vec![0; declared_size] to pre-allocate before validating the compressed body. The 5-byte payload declaring approximately 4 GiB decompressed size yields an amplification ratio of approximately 858 million times, well under the 10 MiB max_transmit_size wire limit.

Test 2 confirms the production gossip configuration uses ValidationMode::None (no signature or source validation at the codec layer) and MessageAuthenticity::Anonymous (IdentityTransform passes raw data through unchanged), while max_transmit_size is only 10 MiB (constraining only the compressed wire-frame size). This means gossip messages pass from TCP inbound all the way to compute_message_id calling decompress_vec with no check on the declared decompressed size at any point.

Test 3 proves the complete attack chain from P2P connection to node crash in a single test. The test uses NetworkBuilder (the same builder used by production consensus nodes) to construct and start a full NetworkDriver, which contains a GossipDriver (libp2p gossipsub, responsible for block propagation) and a Discv5Driver (node discovery protocol). This matches the network initialization path inside Base consensus nodes' RollupNode exactly: NetworkBuilder::new() then NetworkBuilder::build() then NetworkDriver then NetworkDriver::start() then NetworkHandler, followed by the gossip.next() / gossip.handle_event() event loop. The test re-launches its own test binary as a child process, where the child enters victim mode via an environment variable: it sets RLIMIT_AS = 2 GiB and then starts a full NetworkDriver (gossip + discovery). The parent process acts as the attacker, creating a libp2p swarm and connecting to the victim child process over TCP with Noise encryption and Yamux multiplexing. After the gossipsub mesh forms on the blocks topic, the attacker publishes a 5-byte snappy bomb (varint header declaring u32::MAX, approximately 4 GiB decompressed size) via the real gossipsub PUBLISH protocol. The victim's gossipsub receives the message and calls the production compute_message_id, where decompress_vec reads the varint header and executes vec![0; 4294967295], pushing virtual memory to approximately 4.4 GiB. This exceeds the 2 GiB RLIMIT_AS limit, causing the Rust global allocator to fail on mmap and call abort(). The victim child process is terminated by signal 6 (SIGABRT) with exit status 134. The parent process detects the child exit via try_wait(), verifies that the exit signal is 6 (SIGABRT), and confirms the attack succeeded. The entire attack chain executes within the real consensus-layer network protocol stack with no mocking, simulation, or path deviation.

Was this helpful?