Skip to content

Commit 498fb52

Browse files
authored
fix(consensus): cap snappy-decoded gossip payload size (base#2446)
compute_message_id and NetworkPayloadEnvelope::decode_v{1..4} both snappy-decompress untrusted gossip data without bounding the decoded size, allowing an unauthenticated peer to force large heap allocations per packet before any signature, SSZ, or length check runs. Pre-check the declared decoded length via snap::raw::decompress_len (O(1), no allocation) and reject when it exceeds MAX_GOSSIP_SIZE / MAX_DECOMPRESSED_ENVELOPE_BYTES (10 MiB). Mirrors the canonical guard used by paradigmxyz/reth and required by the Ethereum consensus-specs p2p-interface. Adds regression tests at both call sites that build a snappy frame with declared decoded length above the cap and assert rejection without a large allocation. (cherry picked from commit 1c6bc9f)
1 parent 0bbd206 commit 498fb52

3 files changed

Lines changed: 122 additions & 25 deletions

File tree

crates/common/rpc-types-engine/src/envelope.rs

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ use alloy_rpc_types_engine::{
1414

1515
use crate::{BaseExecutionPayload, BaseExecutionPayloadSidecar, BaseExecutionPayloadV4};
1616

17+
/// Maximum allowed decoded size for a snappy-compressed [`NetworkPayloadEnvelope`].
18+
///
19+
/// Mirrors `MAX_GOSSIP_SIZE` in `base-consensus-gossip` and bounds the heap
20+
/// allocation performed by [`NetworkPayloadEnvelope::decode_v1`] and friends.
21+
/// Without this cap, a wire-valid 9 `MiB` snappy frame can declare a 200 `MiB`
22+
/// decoded length and force the decoder to allocate that buffer before any
23+
/// downstream length, SSZ, or signature check runs.
24+
pub const MAX_DECOMPRESSED_ENVELOPE_BYTES: usize = 10 * (1 << 20);
25+
1726
/// A thin wrapper around [`BaseExecutionPayload`] that includes the parent beacon block root.
1827
#[derive(Debug, Clone, PartialEq, Eq)]
1928
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -249,13 +258,31 @@ pub struct NetworkPayloadEnvelope {
249258
}
250259

251260
impl NetworkPayloadEnvelope {
261+
/// Snappy-decompresses `data` after checking that the declared decoded size
262+
/// does not exceed [`MAX_DECOMPRESSED_ENVELOPE_BYTES`].
263+
///
264+
/// `snap::raw::decompress_len` parses only the varu32 preamble and never
265+
/// allocates, so this guard runs in O(1) and prevents an unauthenticated
266+
/// peer from forcing arbitrarily large heap allocations on the consensus
267+
/// node before any signature or SSZ check has run.
268+
#[cfg(feature = "std")]
269+
fn decompress_bounded(data: &[u8]) -> Result<Vec<u8>, PayloadEnvelopeError> {
270+
let declared = snap::raw::decompress_len(data)?;
271+
if declared > MAX_DECOMPRESSED_ENVELOPE_BYTES {
272+
return Err(PayloadEnvelopeError::DecodedTooLarge {
273+
given: declared,
274+
max: MAX_DECOMPRESSED_ENVELOPE_BYTES,
275+
});
276+
}
277+
Ok(snap::raw::Decoder::new().decompress_vec(data)?)
278+
}
279+
252280
/// Decode a payload envelope from a snappy-compressed byte array.
253281
/// The payload version decoded is `ExecutionPayloadV1` from SSZ bytes.
254282
#[cfg(feature = "std")]
255283
pub fn decode_v1(data: &[u8]) -> Result<Self, PayloadEnvelopeError> {
256284
use ssz::Decode;
257-
let mut decoder = snap::raw::Decoder::new();
258-
let decompressed = decoder.decompress_vec(data)?;
285+
let decompressed = Self::decompress_bounded(data)?;
259286

260287
if decompressed.len() < 66 {
261288
return Err(PayloadEnvelopeError::InvalidLength);
@@ -298,8 +325,7 @@ impl NetworkPayloadEnvelope {
298325
#[cfg(feature = "std")]
299326
pub fn decode_v2(data: &[u8]) -> Result<Self, PayloadEnvelopeError> {
300327
use ssz::Decode;
301-
let mut decoder = snap::raw::Decoder::new();
302-
let decompressed = decoder.decompress_vec(data)?;
328+
let decompressed = Self::decompress_bounded(data)?;
303329

304330
if decompressed.len() < 66 {
305331
return Err(PayloadEnvelopeError::InvalidLength);
@@ -342,8 +368,7 @@ impl NetworkPayloadEnvelope {
342368
#[cfg(feature = "std")]
343369
pub fn decode_v3(data: &[u8]) -> Result<Self, PayloadEnvelopeError> {
344370
use ssz::Decode;
345-
let mut decoder = snap::raw::Decoder::new();
346-
let decompressed = decoder.decompress_vec(data)?;
371+
let decompressed = Self::decompress_bounded(data)?;
347372

348373
if decompressed.len() < 98 {
349374
return Err(PayloadEnvelopeError::InvalidLength);
@@ -398,8 +423,7 @@ impl NetworkPayloadEnvelope {
398423
#[cfg(feature = "std")]
399424
pub fn decode_v4(data: &[u8]) -> Result<Self, PayloadEnvelopeError> {
400425
use ssz::Decode;
401-
let mut decoder = snap::raw::Decoder::new();
402-
let decompressed = decoder.decompress_vec(data)?;
426+
let decompressed = Self::decompress_bounded(data)?;
403427

404428
if decompressed.len() < 98 {
405429
return Err(PayloadEnvelopeError::InvalidLength);
@@ -478,6 +502,14 @@ pub enum PayloadEnvelopeError {
478502
/// The payload envelope is of invalid length.
479503
#[error("Invalid length")]
480504
InvalidLength,
505+
/// The declared decompressed size exceeds [`MAX_DECOMPRESSED_ENVELOPE_BYTES`].
506+
#[error("Decoded payload too large: {given} > {max}")]
507+
DecodedTooLarge {
508+
/// Declared decoded size in bytes.
509+
given: usize,
510+
/// Maximum allowed decoded size in bytes.
511+
max: usize,
512+
},
481513
}
482514

483515
impl From<alloy_primitives::SignatureError> for PayloadEnvelopeError {
@@ -634,4 +666,28 @@ mod tests {
634666
let encoded = payload_envelop.encode_v4().unwrap();
635667
assert_eq!(data, encoded);
636668
}
669+
670+
#[test]
671+
#[cfg(feature = "std")]
672+
fn test_decode_rejects_oversized_decompressed_payload() {
673+
let huge = vec![0u8; MAX_DECOMPRESSED_ENVELOPE_BYTES + 1];
674+
let bomb = snap::raw::Encoder::new().compress_vec(&huge).unwrap();
675+
676+
let declared = snap::raw::decompress_len(&bomb).unwrap();
677+
assert!(declared > MAX_DECOMPRESSED_ENVELOPE_BYTES);
678+
assert!(bomb.len() < MAX_DECOMPRESSED_ENVELOPE_BYTES);
679+
680+
for result in [
681+
NetworkPayloadEnvelope::decode_v1(&bomb),
682+
NetworkPayloadEnvelope::decode_v2(&bomb),
683+
NetworkPayloadEnvelope::decode_v3(&bomb),
684+
NetworkPayloadEnvelope::decode_v4(&bomb),
685+
] {
686+
assert!(matches!(
687+
result,
688+
Err(PayloadEnvelopeError::DecodedTooLarge { given, max })
689+
if given == declared && max == MAX_DECOMPRESSED_ENVELOPE_BYTES
690+
));
691+
}
692+
}
637693
}

crates/common/rpc-types-engine/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ pub use attributes::BasePayloadAttributes;
1515

1616
mod envelope;
1717
pub use envelope::{
18-
BaseExecutionPayloadEnvelope, ExecutionData, NetworkPayloadEnvelope,
19-
PayloadEnvelopeEncodeError, PayloadEnvelopeError, PayloadHash,
18+
BaseExecutionPayloadEnvelope, ExecutionData, MAX_DECOMPRESSED_ENVELOPE_BYTES,
19+
NetworkPayloadEnvelope, PayloadEnvelopeEncodeError, PayloadEnvelopeError, PayloadHash,
2020
};
2121

2222
mod sidecar;

crates/consensus/gossip/src/config.rs

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,26 +101,48 @@ pub fn default_config() -> Config {
101101
}
102102

103103
/// Computes the [`MessageId`] of a `gossipsub` message.
104+
///
105+
/// Reject oversized snappy frames before allocating: `snap::raw::decompress_len`
106+
/// parses only the varu32 preamble and never allocates. Frames whose declared
107+
/// decoded size exceeds [`MAX_GOSSIP_SIZE`] are hashed under the invalid-snappy
108+
/// domain, identical to malformed snappy input. This prevents an anonymous peer
109+
/// from forcing the gossipsub task to allocate hundreds of `MiB` per packet.
104110
fn compute_message_id(msg: &Message) -> MessageId {
105-
let mut decoder = Decoder::new();
106-
let id = decoder.decompress_vec(&msg.data).map_or_else(
107-
|_| {
108-
warn!(target: "cfg", "Failed to decompress message, using invalid snappy");
109-
let domain_invalid_snappy: Vec<u8> = vec![0x0, 0x0, 0x0, 0x0];
110-
sha256([domain_invalid_snappy.as_slice(), msg.data.as_slice()].concat().as_slice())
111-
[..20]
112-
.to_vec()
113-
},
114-
|data| {
115-
let domain_valid_snappy: Vec<u8> = vec![0x1, 0x0, 0x0, 0x0];
116-
sha256([domain_valid_snappy.as_slice(), data.as_slice()].concat().as_slice())[..20]
117-
.to_vec()
118-
},
119-
);
111+
let id = match snap::raw::decompress_len(&msg.data) {
112+
Ok(declared) if declared > MAX_GOSSIP_SIZE => {
113+
warn!(target: "cfg", declared, max = MAX_GOSSIP_SIZE, "Rejecting oversized snappy message");
114+
invalid_snappy_id(&msg.data)
115+
}
116+
Ok(_) => {
117+
let mut decoder = Decoder::new();
118+
decoder.decompress_vec(&msg.data).map_or_else(
119+
|_| {
120+
warn!(target: "cfg", "Failed to decompress message, using invalid snappy");
121+
invalid_snappy_id(&msg.data)
122+
},
123+
|data| {
124+
let domain_valid_snappy: Vec<u8> = vec![0x1, 0x0, 0x0, 0x0];
125+
sha256([domain_valid_snappy.as_slice(), data.as_slice()].concat().as_slice())
126+
[..20]
127+
.to_vec()
128+
},
129+
)
130+
}
131+
Err(_) => {
132+
warn!(target: "cfg", "Failed to read snappy preamble, using invalid snappy");
133+
invalid_snappy_id(&msg.data)
134+
}
135+
};
120136

121137
MessageId(id)
122138
}
123139

140+
/// Hash `data` under the invalid-snappy domain tag.
141+
fn invalid_snappy_id(data: &[u8]) -> Vec<u8> {
142+
let domain_invalid_snappy: Vec<u8> = vec![0x0, 0x0, 0x0, 0x0];
143+
sha256([domain_invalid_snappy.as_slice(), data].concat().as_slice())[..20].to_vec()
144+
}
145+
124146
#[cfg(test)]
125147
mod tests {
126148
use super::*;
@@ -161,4 +183,23 @@ mod tests {
161183
let hashed = sha256(&[&[0x1, 0x0, 0x0, 0x0], [1, 2, 3, 4, 5].as_slice()].concat());
162184
assert_eq!(id.0, hashed[..20].to_vec());
163185
}
186+
187+
#[test]
188+
fn test_compute_message_id_rejects_oversized_snappy_bomb() {
189+
let huge = vec![0u8; MAX_GOSSIP_SIZE + 1];
190+
let bomb = snap::raw::Encoder::new().compress_vec(&huge).unwrap();
191+
assert!(bomb.len() < MAX_GOSSIP_SIZE, "wire size must pass max_transmit_size");
192+
assert!(snap::raw::decompress_len(&bomb).unwrap() > MAX_GOSSIP_SIZE);
193+
194+
let msg = Message {
195+
source: None,
196+
data: bomb.clone(),
197+
sequence_number: None,
198+
topic: libp2p::gossipsub::TopicHash::from_raw("test"),
199+
};
200+
201+
let id = compute_message_id(&msg);
202+
let expected = sha256(&[&[0x0, 0x0, 0x0, 0x0], bomb.as_slice()].concat())[..20].to_vec();
203+
assert_eq!(id.0, expected, "oversized bomb must hash under the invalid-snappy domain");
204+
}
164205
}

0 commit comments

Comments
 (0)