consensus: Add CompactSize range check to deserialization
What changed, and why it matters
This commit adds a safety limit when reading Bitcoin's 'CompactSize' numbers from serialized data. Before, a malformed message could claim an absurdly large number of elements (for example, billions of items in a list), which could be used to exhaust memory or trigger a panic. Now values above Bitcoin Core's documented maximum are rejected early during parsing. The change also refactors the parsing code so tests can still check non-minimal encoding without the new limit.
Review callers that previously handled large CompactSize values to ensure they tolerate the new error. Confirm that the internal test-only bypass (range_check=false) is not exposed to untrusted input paths. Consider fuzzing the CompactSize decoder around the 0x02000000 boundary and the non-minimal encoding cases.
Security signals we found
Adds an upper-bound check to a length-prefix decoder
Aligns with Bitcoin Core's documented MAX_COMPACT_SIZE limit
Prevents oversized vector/element allocation claims during deserialization
Introduces a new explicit error variant for oversized CompactSize values
Moves error detection earlier in the deserialization pipeline
Evidence from the diff
The patch introduces MAX_COMPACT_SIZE (0x02000000, matching Bitcoin Core’s serialize.h) and rejects decoded CompactSize values above it during deserialization, returning a new ParseError::OversizedCompactSize. The existing read_compact_size logic is extracted into read_compact_size_internal with a range_check flag so unit tests can validate non-minimal encoding behavior separately. The serde adapter and bip152 tests are updated to expect the new error path.
Changed components
bitcoin/src/consensus/encode.rsbitcoin/src/consensus/error.rsbitcoin/src/consensus/serde.rsp2p/src/bip152.rsInspect captured patch +51 / −18
diff --git a/bitcoin/src/consensus/encode.rs b/bitcoin/src/consensus/encode.rs
index 5f398b5c..ab7d1e8d 100644
--- a/bitcoin/src/consensus/encode.rs
+++ b/bitcoin/src/consensus/encode.rs
@@ -217,41 +217,53 @@ impl<R: Read + ?Sized> ReadExt for R {
#[inline]
fn read_slice(&mut self, slice: &mut [u8]) -> Result<(), Error> { Ok(self.read_exact(slice)?) }
#[inline]
- #[rustfmt::skip] // Formatter munges code comments below.
- fn read_compact_size(&mut self) -> Result<u64, Error> {
- match self.read_u8()? {
+ fn read_compact_size(&mut self) -> Result<u64, Error> { read_compact_size_internal(self, true) }
+}
+
+#[rustfmt::skip] // Formatter munges code comments below.
+fn read_compact_size_internal<R: Read + ?Sized>(r: &mut R, range_check: bool)-> Result<u64, Error> {
+ let x = match r.read_u8()? {
0xFF => {
- let x = self.read_u64()?;
+ let x = r.read_u64()?;
if x < 0x1_0000_0000 { // I.e., would have fit in a `u32`.
- Err(ParseError::NonMinimalCompactSize.into())
+ return Err(ParseError::NonMinimalCompactSize.into());
} else {
- Ok(x)
+ x
}
}
0xFE => {
- let x = self.read_u32()?;
+ let x = r.read_u32()?;
if x < 0x1_0000 { // I.e., would have fit in a `u16`.
- Err(ParseError::NonMinimalCompactSize.into())
+ return Err(ParseError::NonMinimalCompactSize.into());
} else {
- Ok(x as u64)
+ x as u64
}
}
0xFD => {
- let x = self.read_u16()?;
- if x < 0xFD { // Could have been encoded as a `u8`.
- Err(ParseError::NonMinimalCompactSize.into())
+ let x = r.read_u16()?;
+ if x < 0xFD { // Could have been encoded as a `u8`.
+ return Err(ParseError::NonMinimalCompactSize.into());
} else {
- Ok(x as u64)
+ x as u64
}
}
- n => Ok(n as u64),
+ n => n as u64,
+ };
+ if range_check && x > MAX_COMPACT_SIZE as u64 {
+ Err(ParseError::OversizedCompactSize.into())
+ } else {
+ Ok(x)
}
}
-}
/// Maximum size, in bytes, of a vector we are allowed to decode.
pub const MAX_VEC_SIZE: usize = 4_000_000;
+/// The maximum size of a serialized object in bytes or number of elements
+/// (for eg vectors) when the size is encoded as CompactSize.
+/// <https://github.com/bitcoin/bitcoin/blob/a7c29df0e5ace05b6186612671d6103c112ec922/src/serialize.h#L32>
+pub const MAX_COMPACT_SIZE: usize = 0x02000000;
+
/// Data which can be encoded in a consensus-consistent way.
pub trait Encodable {
/// Encodes an object with a well-defined format.
@@ -757,7 +769,7 @@ mod tests {
let mut input = [0u8; 9];
input[0] = n;
input[1..x.len() + 1].copy_from_slice(x);
- (&input[..]).read_compact_size()
+ read_compact_size_internal(&mut &input[..], false)
}
#[test]
@@ -849,6 +861,22 @@ mod tests {
}
}
+ #[test]
+ fn deserialize_compact_size_too_large() {
+ // MAX_COMPACT_SIZE (0x02000000) should succeed
+ assert_eq!(test_varint_encode(0xFE, &(0x02000000_u64).to_le_bytes()).unwrap(), 0x02000000);
+ // MAX_COMPACT_SIZE + 1 should fail with range check enabled
+ let mut input = [0u8; 9];
+ input[0] = 0xFE;
+ input[1..5].copy_from_slice(&(0x02000001_u32).to_le_bytes());
+ assert_eq!(
+ discriminant(&(&mut &input[..]).read_compact_size().unwrap_err()),
+ discriminant(&ParseError::OversizedCompactSize.into())
+ );
+ // Same value without range check should succeed
+ assert_eq!(read_compact_size_internal(&mut &input[..], false).unwrap(), 0x02000001);
+ }
+
#[test]
fn deserialize_nonminimal_vec() {
// Check the edges for variant int
diff --git a/bitcoin/src/consensus/error.rs b/bitcoin/src/consensus/error.rs
index d16b1a3f..f6d7ec24 100644
--- a/bitcoin/src/consensus/error.rs
+++ b/bitcoin/src/consensus/error.rs
@@ -157,6 +157,8 @@ pub enum ParseError {
},
/// CompactSize was encoded in a non-minimal way.
NonMinimalCompactSize,
+ /// CompactSize value exceeds the maximum allowed size.
+ OversizedCompactSize,
/// Parsing error.
ParseFailed(&'static str),
/// Unsupported SegWit flag.
@@ -181,6 +183,7 @@ impl fmt::Display for ParseError {
e[0], e[1], e[2], e[3], a[0], a[1], a[2], a[3],
),
Self::NonMinimalCompactSize => write!(f, "non-minimal compact size"),
+ Self::OversizedCompactSize => write!(f, "value exceeds the maximum allowed compact size"),
Self::ParseFailed(ref s) => write!(f, "parse failed: {}", s),
Self::UnsupportedSegwitFlag(ref swflag) =>
write!(f, "unsupported SegWit version: {}", swflag),
@@ -198,6 +201,7 @@ impl std::error::Error for ParseError {
| Self::OversizedVectorAllocation { .. }
| Self::InvalidChecksum { .. }
| Self::NonMinimalCompactSize
+ | Self::OversizedCompactSize
| Self::ParseFailed(_)
| Self::UnsupportedSegwitFlag(_) => None,
}
diff --git a/bitcoin/src/consensus/serde.rs b/bitcoin/src/consensus/serde.rs
index 79a3e7d7..e6255f7f 100644
--- a/bitcoin/src/consensus/serde.rs
+++ b/bitcoin/src/consensus/serde.rs
@@ -375,6 +375,8 @@ fn consensus_error_into_serde<E: serde::de::Error>(error: ParseError) -> E {
),
ParseError::NonMinimalCompactSize =>
E::custom(format_args!("compact size was not encoded minimally")),
+ ParseError::OversizedCompactSize =>
+ E::custom(format_args!("compact size value exceeds the maximum allowed size")),
ParseError::ParseFailed(msg) => E::custom(msg),
ParseError::UnsupportedSegwitFlag(flag) =>
E::invalid_value(Unexpected::Unsigned(flag.into()), &"segwit version 1 flag"),
diff --git a/p2p/src/bip152.rs b/p2p/src/bip152.rs
index a8431372..94173acc 100644
--- a/p2p/src/bip152.rs
+++ b/p2p/src/bip152.rs
@@ -754,8 +754,7 @@ mod test {
// test that we return Err() if deserialization fails (and don't panic)
let mut raw: Vec<u8> = [0u8; 32].to_vec();
raw.extend(errorcase);
- let get_block_txn = deserialize::<BlockTransactionsRequest>(&raw.clone()).unwrap();
- assert!(get_block_txn.indices().is_err());
+ assert!(deserialize::<BlockTransactionsRequest>(&raw).is_err());
}
}
}
Why this scored 61/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.