Merge rust-bitcoin/rust-bitcoin#6919: Sanitize serde size hints before allocating
What changed, and why it matters
This commit fixes a denial-of-service weakness in how the library deserializes lists of Bitcoin data (witnesses, amounts, fee rates) from untrusted input. Before the fix, a few bytes of attacker-controlled data could claim a list would contain billions of items, causing the program to reserve a huge block of memory and crash or be killed. The patch caps how much memory is reserved up front, matching a well-known safeguard used by the serde library itself.
Upgrade to a release containing this merge commit. If you maintain downstream code that deserializes rust-bitcoin types from length-prefixed formats (bincode, postcard, CBOR, etc.), ensure you are on the patched version. No other immediate action is required; the fix is self-contained and includes regression tests.
Security signals we found
Untrusted serde size hint fed directly into Vec::with_capacity
Potential memory exhaustion / OOM kill from small malicious input
Denial-of-service vector in deserialization paths
Fix mirrors serde's own 1 MiB preallocation cap
Regression tests simulate maximum possible size hint
Evidence from the diff
The merge introduces internals::serde::cautious_size_hint, which clamps SeqAccess::size_hint() to at most 1 MiB worth of elements (mirroring serde’s own Vec helper) before calling Vec::with_capacity. It replaces raw Vec::with_capacity(seq.size_hint().unwrap_or(0)) calls in primitives/src/witness.rs and several units serde modules (amount::serde::{as_sat,as_btc,as_str} and fee_rate::serde::{as_sat_per_kwu_floor,as_sat_per_vb_floor,as_sat_per_vb_ceil}). Regression tests use a fake SeqAccess returning usize::MAX to prove deserialization still succeeds with an empty result instead of attempting a massive allocation. The PR description notes a similar issue exists in p2p but was left out of scope because it is a consensus decoder, not serde.
Changed components
internals/src/serde.rsprimitives/src/witness.rsunits/src/amount/serde.rsunits/src/fee_rate/serde.rsInspect captured patch +88 / −10
### internals/src/serde.rs
@@ -65,3 +65,14 @@ macro_rules! serde_string_impl {
$crate::serde_string_serialize_impl!($name, $expecting);
};
}
+
+/// Upper and lower bound a sequence size hint before preallocating, the same way serde does for `Vec`.
+pub fn cautious_size_hint<T>(hint: Option<usize>) -> usize {
+ // The 1MB limit below was taken from serde
+ // https://github.com/serde-rs/serde/blob/master/serde_core/src/private/size_hint.rs
+ const MAX_PREALLOC_BYTES: usize = 1024 * 1024;
+ match MAX_PREALLOC_BYTES.checked_div(core::mem::size_of::<T>()) {
+ Some(max) => hint.unwrap_or(0).min(max),
+ None => 0,
+ }
+}
### primitives/src/witness.rs
@@ -811,10 +811,9 @@ impl<'de> serde::Deserialize<'de> for Witness {
self,
mut a: A,
) -> Result<Self::Value, A::Error> {
- let mut ret = match a.size_hint() {
- Some(len) => Vec::with_capacity(len),
- None => Vec::new(),
- };
+ let mut ret: Vec<Vec<u8>> = Vec::with_capacity(
+ internals::serde::cautious_size_hint::<Vec<u8>>(a.size_hint()),
+ );
while let Some(elem) = a.next_element::<String>()? {
let vec = hex::decode_to_vec(&elem).map_err(serde::de::Error::custom)?;
@@ -1384,6 +1383,29 @@ mod test {
witness
}
+ #[test]
+ #[cfg(feature = "serde")]
+ fn serde_does_not_trust_size_hint() {
+ use serde::de::value::{Error, SeqAccessDeserializer};
+ use serde::de::{DeserializeSeed, SeqAccess};
+
+ struct FakeHugeHint;
+ impl<'de> SeqAccess<'de> for FakeHugeHint {
+ type Error = Error;
+ fn next_element_seed<T: DeserializeSeed<'de>>(
+ &mut self,
+ _: T,
+ ) -> Result<Option<T::Value>, Error> {
+ Ok(None)
+ }
+ fn size_hint(&self) -> Option<usize> { Some(usize::MAX) }
+ }
+
+ let witness: Witness =
+ serde::Deserialize::deserialize(SeqAccessDeserializer::new(FakeHugeHint)).unwrap();
+ assert!(witness.is_empty());
+ }
+
#[test]
#[cfg(feature = "serde")]
fn serde_bincode_roundtrips() {
### units/src/amount/serde.rs
@@ -281,7 +281,9 @@ pub mod as_sat {
#[serde(transparent)]
struct Wrapper<T: TryFrom<SignedAmount>>(#[serde(with = "super")] T) where T::Error: core::fmt::Display;
- let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
+ let mut out = Vec::with_capacity(internals::serde::cautious_size_hint::<X>(
+ seq.size_hint(),
+ ));
while let Some(wrapped) = seq.next_element::<Wrapper<X>>()? {
out.push(wrapped.0);
}
@@ -449,7 +451,9 @@ pub mod as_btc {
#[serde(transparent)]
struct Wrapper(#[serde(with = "super")] SignedAmount);
- let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
+ let mut out = Vec::with_capacity(internals::serde::cautious_size_hint::<X>(
+ seq.size_hint(),
+ ));
while let Some(wrapped) = seq.next_element::<Wrapper>()? {
out.push(X::try_from(wrapped.0).map_err(de::Error::custom)?);
}
@@ -619,7 +623,9 @@ pub mod as_str {
#[serde(transparent)]
struct Wrapper(#[serde(with = "super")] SignedAmount);
- let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
+ let mut out = Vec::with_capacity(internals::serde::cautious_size_hint::<X>(
+ seq.size_hint(),
+ ));
while let Some(wrapped) = seq.next_element::<Wrapper>()? {
out.push(X::try_from(wrapped.0).map_err(de::Error::custom)?);
}
### units/src/fee_rate/serde.rs
@@ -139,7 +139,9 @@ pub mod as_sat_per_kwu_floor {
#[serde(transparent)]
struct Wrapper(#[serde(with = "super")] FeeRate);
- let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
+ let mut out = Vec::with_capacity(
+ internals::serde::cautious_size_hint::<FeeRate>(seq.size_hint()),
+ );
while let Some(wrapped) = seq.next_element::<Wrapper>()? {
out.push(wrapped.0);
}
@@ -265,7 +267,9 @@ pub mod as_sat_per_vb_floor {
#[serde(transparent)]
struct Wrapper(#[serde(with = "super")] FeeRate);
- let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
+ let mut out = Vec::with_capacity(
+ internals::serde::cautious_size_hint::<FeeRate>(seq.size_hint()),
+ );
while let Some(wrapped) = seq.next_element::<Wrapper>()? {
out.push(wrapped.0);
}
@@ -391,7 +395,9 @@ pub mod as_sat_per_vb_ceil {
#[serde(transparent)]
struct Wrapper(#[serde(with = "super")] FeeRate);
- let mut out = Vec::with_capacity(seq.size_hint().unwrap_or(0));
+ let mut out = Vec::with_capacity(
+ internals::serde::cautious_size_hint::<FeeRate>(seq.size_hint()),
+ );
while let Some(wrapped) = seq.next_element::<Wrapper>()? {
out.push(wrapped.0);
}
### units/tests/serde.rs
@@ -864,3 +864,36 @@ fn serde_regression_target() {
let want = include_bytes!("data/u256_bincode") as &[_];
assert_eq!(got, want);
}
+
+#[test]
+fn vec_deserializers_do_not_trust_size_hint() {
+ use serde::de::value::{Error, SeqAccessDeserializer};
+ use serde::de::{DeserializeSeed, SeqAccess};
+
+ struct FakeHugeHint;
+ impl<'de> SeqAccess<'de> for FakeHugeHint {
+ type Error = Error;
+ fn next_element_seed<T: DeserializeSeed<'de>>(
+ &mut self,
+ _: T,
+ ) -> Result<Option<T::Value>, Error> {
+ Ok(None)
+ }
+ fn size_hint(&self) -> Option<usize> { Some(usize::MAX) }
+ }
+
+ macro_rules! check_vec_alloc_succeeds {
+ ($($deserializer:path => $expected_type:ty),* $(,)?) => {$(
+ let got: Vec<$expected_type> = $deserializer(SeqAccessDeserializer::new(FakeHugeHint)).unwrap();
+ assert!(got.is_empty());
+ )*};
+ }
+ check_vec_alloc_succeeds!(
+ amount::serde::as_sat::vec::deserialize => Amount,
+ amount::serde::as_btc::vec::deserialize => Amount,
+ amount::serde::as_str::vec::deserialize => Amount,
+ fee_rate::serde::as_sat_per_kwu_floor::vec::deserialize => FeeRate,
+ fee_rate::serde::as_sat_per_vb_floor::vec::deserialize => FeeRate,
+ fee_rate::serde::as_sat_per_vb_ceil::vec::deserialize => FeeRate,
+ );
+}Why this scored 70/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.