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

74513 bc critical unbounded snappy decompression in gossipsub message id fn causes pre validation cpu and memory exhaustion from a mesh peer

Submitted on Apr 23rd 2026 at 05:40:10 UTC by @kaiserlimp0 for Audit Comp | Base Azul

  • Report ID: #74513

  • Report Type: Blockchain/DLT

  • Report severity: Critical

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

  • Impacts:

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

    • 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

Description

Root Cause

In-scope asset: https://github.com/base/base/releases/tag/v0.8.0-rc.15 (Base Azul Offchain Components). The vulnerable code is under crates/consensus/gossip, i.e. Base-native offchain consensus networking, and is not under the Base Azul out-of-scope base/base folders (actions, devnet, baseup, etc).

compute_message_id is installed as the libp2p-gossipsub message_id_fn at crates/consensus/gossip/src/config.rs:93. Per the libp2p-gossipsub spec, message_id_fn is invoked on every received message before any application-level validation, i.e. before the unsafe-block signer is verified, before the topic handler runs, and before the envelope is decoded. It is the one Base gossip hook that can consume attacker-controlled message bytes from an established mesh peer before Base-level signer authorization has a chance to reject the message.

The current implementation (lines 104-122) unconditionally invokes snap::raw::Decoder::decompress_vec(&msg.data):

// crates/consensus/gossip/src/config.rs:104-122
fn compute_message_id(msg: &gossipsub::Message) -> gossipsub::MessageId {
    let mut decoder = snap::raw::Decoder::new();
    let bytes = decoder.decompress_vec(&msg.data).map_or_else(
        |_| sha256(&[&[0x0, 0x0, 0x0, 0x0], msg.data.as_slice()].concat())[..20].to_vec(),
        |decompressed| sha256(&[&[0x1, 0x0, 0x0, 0x0], decompressed.as_slice()].concat())[..20].to_vec(),
    );
    gossipsub::MessageId::from(bytes)
}

MAX_GOSSIP_SIZE = 10 * (1 << 20) = 10 MiB is applied as gossipsub max_transmit_size at config.rs:88, but that cap bounds only the wire size of msg.data. snap::raw::Decoder::decompress_vec internally does vec![0u8; snap::raw::decompress_len(input)?], i.e. it allocates and touches whatever uncompressed length the attacker advertises in the snappy varint header, up to the snap crate’s u32::MAX ceiling. No output-size cap is enforced in compute_message_id.

Snappy’s copy_2_byte_offset opcode is a 3-byte instruction that can expand to 64 bytes, i.e. a 21.3x amplification ratio. A well-formed snappy stream of 9,984,442 bytes, still within the 10 MiB wire limit, can fully parse to 213,000,000 bytes (~203 MiB) of legal bytes. decompress_vec accepts this without error, so compute_message_id reaches the Ok(decompressed) branch and then hashes the full 213,000,000-byte buffer with SHA-256.

Cross-client parity

The upstream OP Stack op-node implementation already guards this class of input in the equivalent message-ID path: BuildMsgIdFn reads snappy.DecodedLen(pmsg.Data) and only decompresses if the decoded length is <= maxGossipSize. It also uses a reusable message buffer instead of allocating a fresh Vec per message. The block validator repeats the decoded-length check and logs "possible snappy zip bomb" before rejecting oversized decoded payloads.

The OP Stack P2P spec likewise constrains gossip contents to 10 MiB and explicitly calls out limiting generated decompressed output to avoid zip-bomb-type resource usage. Base’s Rust implementation keeps the same 10 MiB wire-size constant and the same valid/invalid-snappy domain split, but drops the decoded-output-size guard in compute_message_id. This is therefore a Rust-port parity regression, not just a missing optimization.

Key source links:

  • message_id_fn(compute_message_id) registration: https://github.com/base/base/blob/v0.8.0-rc.15/crates/consensus/gossip/src/config.rs#L77-L93

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

  • BlockHandler::handle runs after message_id_fn: https://github.com/base/base/blob/v0.8.0-rc.15/crates/consensus/gossip/src/handler.rs#L50-L76

  • upstream OP Stack decoded-length guard: https://github.com/ethereum-optimism/optimism/blob/35a930195c8046ee6d4a8f5bfc39bcc6cc92e9a8/op-node/p2p/gossip.go#L110-L148

  • upstream OP Stack zip-bomb guard: https://github.com/ethereum-optimism/optimism/blob/35a930195c8046ee6d4a8f5bfc39bcc6cc92e9a8/op-node/p2p/gossip.go#L268-L285

  • OP Stack P2P spec: https://specs.optimism.io/protocol/rollup-node-p2p.html#message-compression-and-limits

Reachability

This is the load-bearing property of the finding: compute_message_id runs before Base signer/topic validation. Later code paths such as BlockHandler::handle and the envelope decoders in crates/common/rpc-types-engine/src/envelope.rs are downstream of the unsafe-block signer check and are therefore not reachable by an unauthenticated mesh peer.

The attack surface is the libp2p-gossipsub mesh slot itself. Any peer admitted into the mesh can send these messages, and the victim will pay the memory and CPU cost in message_id_fn before downstream Base validation can reject the message or penalize the peer.

In the local reproduction, mesh admission was obtained by connecting to the follower over an explicit local/private /p2p target and subscribing to /optimism/84538453/3/blocks; the vulnerable work then executes in message_id_fn before Base’s unsafe-block signer validation.

Peer scoring does not remove the root cause. A peer may be penalized only after the message has already triggered the unbounded snappy allocation and SHA-256 work. Since Base uses GOSSIP_HEARTBEAT = 500 ms, a single ~2.3-second message_id_fn computation blocks roughly 4.6 heartbeat intervals on the gossipsub task.

Impact

Measured per-message victim cost in the standalone reproduction of the vulnerable function:

Measured output from the standalone PoC:

This directly proves the Medium impact class: “Increasing network processing node resource consumption by at least 30% without brute force actions.”

To bridge this from the standalone process to an actual Base node baseline:

That means the single attacker-controlled pre-validation allocation alone is enough to exceed the 30% resource-consumption threshold on a normal consensus node baseline.

I also ran a local multi-follower Base Azul devnet experiment against loopback/private Docker services only:

Each harness confirmed a direct local connection and target subscription to /optimism/84538453/3/blocks before publishing. This demonstrates that the same payload can be driven through Base's local gossipsub transport and can degrade an entire local follower fleet's visible L2 head while the builder continues producing blocks. I do not rely on downstream Event::Message counters for this claim: the vulnerable message_id_fn hook runs before Base's driver emits Event::Message and before the application-layer handler metrics.

The local devnet run supports the High-tier Blockchain/DLT impact “Temporary freezing of network transactions by delaying one block by 500% or more of the average block time.” In the local Base Azul devnet, each follower advanced 22 blocks during the 45-second baseline window, i.e. about 2.05 seconds per block, then stalled while the builder kept producing blocks, with visible unsafe-head latency reaching ~84-91 seconds and final builder-vs-node lag reaching 40 blocks. That local delay is roughly 41x-44x the measured baseline block interval, i.e. about 4,100%-4,400% of baseline, well above the 500% threshold.

I am therefore requesting High based on the local 500%+ delay evidence, while remaining open to Medium if Immunefi/Base require public-network or production-fleet measurements for High.

No public block delay, public RPC/API crash, chain split, public testnet, mainnet, public bootnode, or third-party endpoint was used.

Prerequisites

  • Attacker obtains at least one gossipsub mesh slot with the victim.

  • No admin role, no signer, no TEE key, no L1 state, no flash loan, no MEV ordering.

  • Only requires crafting a deterministic snappy blob.

Fix Recommendation

Replace the unbounded decompress_vec in crates/consensus/gossip/src/config.rs:106 with a bounded decompress that rejects when the advertised or actual output exceeds MAX_GOSSIP_SIZE:

This matches the upstream op-node behavior and restores the missing decoded-size invariant before allocation.

Policy / safe-testing note

All active reproduction in this report was limited to a local standalone process and local Docker devnet transport/fleet checks. I did not run the payload against Base mainnet, a public Base testnet, public bootnodes, public RPCs, or any third-party infrastructure.

If useful for triage, I can provide:

  • the exact local console output from the standalone measured run

  • raw local devnet logs / JSONL / harness output

Proof of Concept

1

Reproduce the vulnerable function locally

Reproduce the vulnerable function locally with the standalone Rust PoC below. It mirrors compute_message_id byte-for-byte and feeds it a valid snappy frame that stays under the 10 MiB wire cap while expanding to 213,000,000 bytes.

2

Run the PoC and observe resource usage

Run the PoC and observe that a single call to compute_message_id_like_base causes a transient RSS increase of about 208,156 KiB and takes about 2.3 seconds of single-core CPU time.

3

Confirm the vulnerable path in source

Confirm against source that the same vulnerable path is registered as gossipsub message_id_fn and is executed before Base signer/topic validation.

4

Optionally reproduce through the local devnet

Optionally, reproduce the same payload through the local Base Azul Docker devnet transport and observe the follower-fleet lag metrics summarized below.

Standalone runnable PoC

Expected measured output from the reproduced run:

This captures the transient allocation spike while the vulnerable decompress_vec call and the subsequent SHA-256 pass are still executing.

Local devnet reproduction summary

I also reproduced the same payload only against a local/private Base Azul Docker devnet. Four follower nodes were affected simultaneously while the builder continued producing blocks:

Raw local devnet logs / JSONL / harness output are available on request.

Notes

  • This PoC is a standalone/local reproduction and local devnet reproduction only.

  • No public testnet, mainnet, public bootnode, public RPC, or third-party system was targeted.

  • Network reachability follows from message_id_fn(compute_message_id) being registered at crates/consensus/gossip/src/config.rs:93, which libp2p-gossipsub executes before application-level validation.

Was this helpful?