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-L93vulnerable
compute_message_id: https://github.com/base/base/blob/v0.8.0-rc.15/crates/consensus/gossip/src/config.rs#L104-L122BlockHandler::handleruns aftermessage_id_fn: https://github.com/base/base/blob/v0.8.0-rc.15/crates/consensus/gossip/src/handler.rs#L50-L76upstream 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
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 atcrates/consensus/gossip/src/config.rs:93, which libp2p-gossipsub executes before application-level validation.
Was this helpful?