Increasing network processing node resource consumption by at least 30% without brute force actions, compared to the preceding 24 hours
Description
Brief/Intro
Every Base consensus node derives a gossipsub message-id by Snappy-decompressing the raw message bytes inside compute_message_id, which runs on the hot, pre-validation path for every inbound gossip message from every mesh peer. Because the implementation calls snap::raw::Decoder::decompress_vecwithout first checking snap::raw::decompress_len against MAX_GOSSIP_SIZE, the output buffer is sized from an attacker-controlled 1–5-byte varint in the frame header. In production this lets any peer on the public gossip mesh publish tiny messages (~a few bytes of header) that force every receiving Base node to perform multi-gigabyte heap allocations and bulk SHA-256 hashing per message, yielding a one-to-many CPU/memory amplification that trivially clears the "≥30% increase in node resource consumption" bar and, at the extreme, OOM-kills validator / sequencer / verifier nodes network-wide.
compute_message_id is installed as the gossipsub message_id_fn, so libp2p-gossipsub invokes it for every received message before deduplication (the seen-cache lookup is keyed on the id it returns) and before any semantic validation:
The function itself is:
The crate defines a 10 MiB cap and its doc-comment explicitly claims it bounds decompressed individual messages:
but the only place this constant is wired into the gossipsub config is .max_transmit_size(MAX_GOSSIP_SIZE). max_transmit_size is a libp2p-gossipsub setting that bounds the on-wire, compressed RPC size - not what decompress_vec allocates after the bytes are in memory. Nothing in compute_message_id consults snap::raw::decompress_len or caps the output buffer, so the claim in the doc-comment is not enforced on this path.
The attack itself goes as following:
an attacker-published gossip message carrying only:
declares declared_len ≈ 4 GiB in 5 bytes of header. Every receiving Base node, on seeing this message for dedup, runs:
libp2p-gossipsub transport accepts it (≪ 10 MiB, so max_transmit_size is satisfied).
compute_message_id is invoked.
decompress_vec reads the varint, calls vec![0; ~4 GiB] — a multi-gigabyte zero-initialised heap allocation that touches every page (real RSS, real wall time). Either the body is valid and the full decompressed buffer is then fed through sha256([0x01, 0x00, 0x00, 0x00] || data) (more CPU), or the body is invalid, the allocation is dropped, and the invalid-snappy branch is taken — but the allocation already happened.
Impact Details
Increasing network processing node resource consumption by at least 30%
/// Computes the [`MessageId`] of a `gossipsub` message.
fn compute_message_id(msg: &Message) -> MessageId {
let mut decoder = Decoder::new();
let id = decoder.decompress_vec(&msg.data).map_or_else(
|_| {
warn!(target: "cfg", "Failed to decompress message, using invalid snappy");
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 maximum gossip size.
/// Limits the total size of gossip RPC containers as well as decompressed individual messages.
pub const MAX_GOSSIP_SIZE: usize = 10 * (1 << 20);
[0x80, 0x80, 0x80, 0x80, 0x0F, <minimal body>]
/// Regression test for: "Gossipsub message-id generation decompresses
/// untrusted Snappy payloads without a decompressed-size cap".
///
/// `compute_message_id` is executed for every received gossip message
/// before any semantic validation, as part of dedup / message-id
/// derivation. The current implementation calls
/// `snap::raw::Decoder::decompress_vec` directly, which allocates a
/// buffer sized from the attacker-supplied uncompressed-length field
/// in the Snappy frame. Nothing caps that value at `MAX_GOSSIP_SIZE`
/// (the `.max_transmit_size(MAX_GOSSIP_SIZE)` builder setting only
/// bounds the *transport* size, not the decompressed allocation), so
/// a single peer can force every receiver to perform an oversized
/// allocation + decompression per message — a cheap CPU/memory
/// amplification vector.
///
/// After the fix, `compute_message_id` must refuse to decompress any
/// frame whose decompressed size would exceed `MAX_GOSSIP_SIZE` and
/// fall through to the invalid-snappy hashing branch instead (or
/// equivalently, decompress into a buffer capped at
/// `MAX_GOSSIP_SIZE` and treat overflow as invalid).
#[test]
fn test_compute_message_id_rejects_oversized_decompressed_payload() {
// Build a *valid* Snappy frame that decompresses to one byte over
// MAX_GOSSIP_SIZE. The payload is highly compressible (a single
// repeated byte), so the compressed form stays small (~hundreds of
// KiB) and keeps the test cheap while still tripping the cap that
// the fix must introduce.
let oversized = MAX_GOSSIP_SIZE + 1;
let raw = vec![0x42u8; oversized];
let compressed = snap::raw::Encoder::new()
.compress_vec(&raw)
.expect("snappy encode of repeated byte must succeed");
let declared = snap::raw::decompress_len(&compressed)
.expect("snappy header must be well-formed");
assert!(
declared > MAX_GOSSIP_SIZE,
"fixture decompressed length ({declared}) must exceed MAX_GOSSIP_SIZE ({MAX_GOSSIP_SIZE})",
);
let msg = Message {
source: None,
data: compressed.clone(),
sequence_number: None,
topic: libp2p::gossipsub::TopicHash::from_raw("test"),
};
let id = compute_message_id(&msg);
// the oversized frame must be handled
let invalid_domain: [u8; 4] = [0x0, 0x0, 0x0, 0x0];
let expected_invalid = sha256(
[invalid_domain.as_slice(), compressed.as_slice()].concat().as_slice(),
)[..20]
.to_vec();
// And it must NOT match the valid-snappy hash, which is what the
// current (vulnerable) implementation produces after performing the
// oversized allocation + decompression
let valid_domain: [u8; 4] = [0x1, 0x0, 0x0, 0x0];
let vulnerable_valid_hash = sha256(
[valid_domain.as_slice(), raw.as_slice()].concat().as_slice(),
)[..20]
.to_vec();
assert_ne!(
id.0, vulnerable_valid_hash,
"compute_message_id must not decompress frames whose declared size exceeds MAX_GOSSIP_SIZE",
);
assert_eq!(
id.0, expected_invalid,
"oversized snappy frames must be routed through the invalid-snappy branch without allocating",
);
}