From 0f41de8803049f531ffe0b04ff8808367bd5572f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Sat, 7 Feb 2026 07:58:10 -0500 Subject: [PATCH 1/4] fix: prevent allocation attacks in deserializers using read_many_iter Replace Vec::with_capacity preallocations with read_many_iter in custom Deserializable implementations to prevent OOM/capacity overflow attacks: - PartialMerkleTree::read_from: use read_many_iter for (NodeIndex, Word) - Smt::read_from: use read_many_iter for (Word, Word) entries - SmtLeaf::read_from: use read_many_iter for (Word, Word) entries Add min_serialized_size() overrides for accurate budget checking: - NodeIndex: 9 bytes (u8 + u64) - Word: 32 bytes (SERIALIZED_SIZE) - PartialMerkleTree: 49 bytes (8 + 9 + 32) - Smt: 65 bytes (1 + 32 + 32) - SmtLeaf: 73 bytes (1 + 8 + 32 + 32) This enables BudgetedReader to enforce tight bounds on allocation sizes before any memory is allocated, preventing malicious inputs from claiming billions of elements while providing only a few bytes of data. Fixes fuzz failures: - smt_serde: no longer OOMs on 6-byte malicious input - merkle: allocation attacks prevented (separate panic in with_leaves on empty input is a pre-existing bug now exposed) --- miden-crypto/src/merkle/index.rs | 5 +++++ miden-crypto/src/merkle/partial_mt/mod.rs | 16 +++++++++------- miden-crypto/src/merkle/smt/full/leaf.rs | 16 ++++++++-------- miden-crypto/src/merkle/smt/full/mod.rs | 15 +++++++++------ miden-crypto/src/word/mod.rs | 4 ++++ 5 files changed, 35 insertions(+), 21 deletions(-) diff --git a/miden-crypto/src/merkle/index.rs b/miden-crypto/src/merkle/index.rs index 522527e09..d5184acc9 100644 --- a/miden-crypto/src/merkle/index.rs +++ b/miden-crypto/src/merkle/index.rs @@ -210,6 +210,11 @@ impl Deserializable for NodeIndex { NodeIndex::new(depth, position) .map_err(|_| DeserializationError::InvalidValue("Invalid index".into())) } + + fn min_serialized_size() -> usize { + // u8 (depth) + u64 (value) + 9 + } } /// Implementation for [`NodeIndex::proof_indices()`]. diff --git a/miden-crypto/src/merkle/partial_mt/mod.rs b/miden-crypto/src/merkle/partial_mt/mod.rs index d0cfa1af3..91f5342d8 100644 --- a/miden-crypto/src/merkle/partial_mt/mod.rs +++ b/miden-crypto/src/merkle/partial_mt/mod.rs @@ -467,14 +467,10 @@ impl Serializable for PartialMerkleTree { impl Deserializable for PartialMerkleTree { fn read_from(source: &mut R) -> Result { let leaves_len = source.read_u64()? as usize; - let mut leaf_nodes = Vec::with_capacity(leaves_len); - // add leaf nodes to the vector - for _ in 0..leaves_len { - let index = NodeIndex::read_from(source)?; - let hash = Word::read_from(source)?; - leaf_nodes.push((index, hash)); - } + // Use read_many_iter to avoid eager allocation and respect BudgetedReader limits + let leaf_nodes: Vec<(NodeIndex, Word)> = + source.read_many_iter(leaves_len)?.collect::>()?; let pmt = PartialMerkleTree::with_leaves(leaf_nodes).map_err(|_| { DeserializationError::InvalidValue("Invalid data for PartialMerkleTree creation".into()) @@ -482,4 +478,10 @@ impl Deserializable for PartialMerkleTree { Ok(pmt) } + + /// Minimum serialized size: u64 length prefix + (NodeIndex + Word) per element + /// NodeIndex = u8 + u64 = 9 bytes, Word = 32 bytes = 41 bytes per element + fn min_serialized_size() -> usize { + 8 + NodeIndex::min_serialized_size() + Word::min_serialized_size() + } } diff --git a/miden-crypto/src/merkle/smt/full/leaf.rs b/miden-crypto/src/merkle/smt/full/leaf.rs index 7ea1dcca9..7826a2df5 100644 --- a/miden-crypto/src/merkle/smt/full/leaf.rs +++ b/miden-crypto/src/merkle/smt/full/leaf.rs @@ -416,18 +416,18 @@ impl Deserializable for SmtLeaf { LeafIndex::new_max_depth(value) }; - // Read: entries - let mut entries: Vec<(Word, Word)> = Vec::new(); - for _ in 0..num_entries { - let key: Word = source.read()?; - let value: Word = source.read()?; - - entries.push((key, value)); - } + // Read: entries using read_many_iter to avoid eager allocation + let entries: Vec<(Word, Word)> = + source.read_many_iter(num_entries)?.collect::>()?; Self::new(entries, leaf_index) .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } + + /// Minimum serialized size: vint64 (num_entries) + u64 (leaf_index) + (Word + Word) per entry + fn min_serialized_size() -> usize { + 1 + 8 + Word::min_serialized_size() + Word::min_serialized_size() + } } // HELPER FUNCTIONS diff --git a/miden-crypto/src/merkle/smt/full/mod.rs b/miden-crypto/src/merkle/smt/full/mod.rs index efa23f7f4..5aa2995bf 100644 --- a/miden-crypto/src/merkle/smt/full/mod.rs +++ b/miden-crypto/src/merkle/smt/full/mod.rs @@ -612,17 +612,20 @@ impl Deserializable for Smt { fn read_from(source: &mut R) -> Result { // Read the number of filled leaves for this Smt let num_filled_leaves = source.read_usize()?; - let mut entries = Vec::with_capacity(num_filled_leaves); - for _ in 0..num_filled_leaves { - let key = source.read()?; - let value = source.read()?; - entries.push((key, value)); - } + // Use read_many_iter to avoid eager allocation and respect BudgetedReader limits + let entries: Vec<(Word, Word)> = + source.read_many_iter(num_filled_leaves)?.collect::>()?; Self::with_entries(entries) .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } + + /// Minimum serialized size: vint64 length prefix + (Word + Word) per element + /// Word = 32 bytes, so 64 bytes per element. Length prefix min = 1 byte. + fn min_serialized_size() -> usize { + 1 + Word::min_serialized_size() + Word::min_serialized_size() + } } // FUZZING diff --git a/miden-crypto/src/word/mod.rs b/miden-crypto/src/word/mod.rs index e6fc2f270..20639b46e 100644 --- a/miden-crypto/src/word/mod.rs +++ b/miden-crypto/src/word/mod.rs @@ -688,6 +688,10 @@ impl Deserializable for Word { Ok(Self(inner)) } + + fn min_serialized_size() -> usize { + Self::SERIALIZED_SIZE + } } // ITERATORS From 58e77b3b1e0fade59a804538369ca3b7bb4369b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Sat, 7 Feb 2026 08:09:01 -0500 Subject: [PATCH 2/4] fix: handle empty partial merkle leaves --- miden-crypto/src/merkle/partial_mt/mod.rs | 7 ++++++- miden-crypto/src/merkle/partial_mt/tests.rs | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/miden-crypto/src/merkle/partial_mt/mod.rs b/miden-crypto/src/merkle/partial_mt/mod.rs index 91f5342d8..a69a310a7 100644 --- a/miden-crypto/src/merkle/partial_mt/mod.rs +++ b/miden-crypto/src/merkle/partial_mt/mod.rs @@ -97,13 +97,18 @@ impl PartialMerkleTree { R: IntoIterator, I: Iterator + ExactSizeIterator, { + let entries = entries.into_iter(); + if entries.len() == 0 { + return Ok(PartialMerkleTree::new()); + } + let mut layers: BTreeMap> = BTreeMap::new(); let mut leaves = BTreeSet::new(); let mut nodes = BTreeMap::new(); // add data to the leaves and nodes maps and also fill layers map, where the key is the // depth of the node and value is its index. - for (node_index, hash) in entries.into_iter() { + for (node_index, hash) in entries { leaves.insert(node_index); nodes.insert(node_index, hash); layers diff --git a/miden-crypto/src/merkle/partial_mt/tests.rs b/miden-crypto/src/merkle/partial_mt/tests.rs index 1a07aafdd..04a6f8a9e 100644 --- a/miden-crypto/src/merkle/partial_mt/tests.rs +++ b/miden-crypto/src/merkle/partial_mt/tests.rs @@ -89,6 +89,17 @@ fn err_with_leaves() { assert!(PartialMerkleTree::with_leaves(leaf_nodes).is_err()); } +/// Checks that `with_leaves()` accepts an empty input and returns an empty tree. +#[test] +fn with_leaves_empty() { + let leaf_nodes: BTreeMap = BTreeMap::new(); + + let pmt = PartialMerkleTree::with_leaves(leaf_nodes).unwrap(); + + assert_eq!(PartialMerkleTree::new().root(), pmt.root()); + assert_eq!(0, pmt.max_depth()); +} + /// Tests that `with_leaves()` returns `EntryIsNotLeaf` error when an entry /// is an ancestor of another entry. #[test] From e60e9d97a3489aa131121d3a59712323700e8251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Sat, 7 Feb 2026 08:24:13 -0500 Subject: [PATCH 3/4] fix: tighten budgeted SMT/PMT deserialization --- miden-crypto/src/merkle/partial_mt/mod.rs | 14 ++++++++----- miden-crypto/src/merkle/partial_mt/tests.rs | 22 ++++++++++++++++++++- miden-crypto/src/merkle/smt/full/leaf.rs | 4 ++-- miden-crypto/src/merkle/smt/full/mod.rs | 5 ++--- miden-crypto/src/merkle/smt/full/tests.rs | 19 ++++++++++++++++++ 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/miden-crypto/src/merkle/partial_mt/mod.rs b/miden-crypto/src/merkle/partial_mt/mod.rs index a69a310a7..d7fbd7157 100644 --- a/miden-crypto/src/merkle/partial_mt/mod.rs +++ b/miden-crypto/src/merkle/partial_mt/mod.rs @@ -88,10 +88,12 @@ impl PartialMerkleTree { /// /// # Errors /// Returns an error if: - /// - If the depth is 0 or is greater than 64. + /// - Any entry has depth 0 or is greater than 64. /// - The number of entries exceeds the maximum tree capacity, that is 2^{depth}. /// - The provided entries contain an insufficient set of nodes. /// - Any entry is an ancestor of another entry (creates hash ambiguity). + /// + /// An empty input returns an empty tree. pub fn with_leaves(entries: R) -> Result where R: IntoIterator, @@ -471,7 +473,10 @@ impl Serializable for PartialMerkleTree { impl Deserializable for PartialMerkleTree { fn read_from(source: &mut R) -> Result { - let leaves_len = source.read_u64()? as usize; + let leaves_len_u64 = source.read_u64()?; + let leaves_len = usize::try_from(leaves_len_u64).map_err(|_| { + DeserializationError::InvalidValue("PartialMerkleTree leaf count too large".into()) + })?; // Use read_many_iter to avoid eager allocation and respect BudgetedReader limits let leaf_nodes: Vec<(NodeIndex, Word)> = @@ -484,9 +489,8 @@ impl Deserializable for PartialMerkleTree { Ok(pmt) } - /// Minimum serialized size: u64 length prefix + (NodeIndex + Word) per element - /// NodeIndex = u8 + u64 = 9 bytes, Word = 32 bytes = 41 bytes per element + /// Minimum serialized size: u64 length prefix (0 entries). fn min_serialized_size() -> usize { - 8 + NodeIndex::min_serialized_size() + Word::min_serialized_size() + 8 } } diff --git a/miden-crypto/src/merkle/partial_mt/tests.rs b/miden-crypto/src/merkle/partial_mt/tests.rs index 04a6f8a9e..75f94fb8f 100644 --- a/miden-crypto/src/merkle/partial_mt/tests.rs +++ b/miden-crypto/src/merkle/partial_mt/tests.rs @@ -4,7 +4,7 @@ use super::{ super::{ MerkleError, MerkleTree, NodeIndex, PartialMerkleTree, int_to_node, store::MerkleStore, }, - Deserializable, InnerNodeInfo, MerkleProof, Serializable, Word, + Deserializable, DeserializationError, InnerNodeInfo, MerkleProof, Serializable, Word, }; // TEST DATA @@ -100,6 +100,26 @@ fn with_leaves_empty() { assert_eq!(0, pmt.max_depth()); } +/// Checks that `read_from_bytes_with_budget()` accepts an empty input. +#[test] +fn deserialize_empty_with_budget() { + let pmt = PartialMerkleTree::new(); + let bytes = pmt.to_bytes(); + + let parsed = PartialMerkleTree::read_from_bytes_with_budget(&bytes, bytes.len()).unwrap(); + assert_eq!(pmt, parsed); +} + +/// Checks that oversized leaf counts are rejected during deserialization. +#[test] +fn deserialize_rejects_oversized_length() { + let mut bytes = Vec::new(); + u64::MAX.write_into(&mut bytes); + + let result = PartialMerkleTree::read_from_bytes_with_budget(&bytes, bytes.len()); + assert!(matches!(result, Err(DeserializationError::InvalidValue(_)))); +} + /// Tests that `with_leaves()` returns `EntryIsNotLeaf` error when an entry /// is an ancestor of another entry. #[test] diff --git a/miden-crypto/src/merkle/smt/full/leaf.rs b/miden-crypto/src/merkle/smt/full/leaf.rs index 7826a2df5..fee6923fb 100644 --- a/miden-crypto/src/merkle/smt/full/leaf.rs +++ b/miden-crypto/src/merkle/smt/full/leaf.rs @@ -424,9 +424,9 @@ impl Deserializable for SmtLeaf { .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } - /// Minimum serialized size: vint64 (num_entries) + u64 (leaf_index) + (Word + Word) per entry + /// Minimum serialized size: vint64 (num_entries) + u64 (leaf_index) with 0 entries. fn min_serialized_size() -> usize { - 1 + 8 + Word::min_serialized_size() + Word::min_serialized_size() + 1 + 8 } } diff --git a/miden-crypto/src/merkle/smt/full/mod.rs b/miden-crypto/src/merkle/smt/full/mod.rs index 5aa2995bf..36871ba4c 100644 --- a/miden-crypto/src/merkle/smt/full/mod.rs +++ b/miden-crypto/src/merkle/smt/full/mod.rs @@ -621,10 +621,9 @@ impl Deserializable for Smt { .map_err(|err| DeserializationError::InvalidValue(err.to_string())) } - /// Minimum serialized size: vint64 length prefix + (Word + Word) per element - /// Word = 32 bytes, so 64 bytes per element. Length prefix min = 1 byte. + /// Minimum serialized size: vint64 length prefix (0 entries). fn min_serialized_size() -> usize { - 1 + Word::min_serialized_size() + Word::min_serialized_size() + 1 } } diff --git a/miden-crypto/src/merkle/smt/full/tests.rs b/miden-crypto/src/merkle/smt/full/tests.rs index 703478445..f191492b7 100644 --- a/miden-crypto/src/merkle/smt/full/tests.rs +++ b/miden-crypto/src/merkle/smt/full/tests.rs @@ -709,6 +709,16 @@ fn test_smt_check_empty_root_constant() { assert_eq!(empty_root_64_depth, Smt::EMPTY_ROOT); } +/// Tests that empty SMT deserializes under a tight budget. +#[test] +fn test_empty_smt_deserialization_with_budget() { + let smt = Smt::default(); + let bytes = smt.to_bytes(); + + let parsed = Smt::read_from_bytes_with_budget(&bytes, bytes.len()).unwrap(); + assert_eq!(smt, parsed); +} + // SMT LEAF // -------------------------------------------------------------------------------------------- @@ -724,6 +734,15 @@ fn test_empty_smt_leaf_serialization() { assert_eq!(empty_leaf, deserialized); } +#[test] +fn test_empty_smt_leaf_deserialization_with_budget() { + let empty_leaf = SmtLeaf::new_empty(LeafIndex::new_max_depth(42)); + let bytes = empty_leaf.to_bytes(); + + let deserialized = SmtLeaf::read_from_bytes_with_budget(&bytes, bytes.len()).unwrap(); + assert_eq!(empty_leaf, deserialized); +} + #[test] fn test_single_smt_leaf_serialization() { let single_leaf = SmtLeaf::new_single( From 5869c1b8dab95d7d6542df944ee1e953b0c2681c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Garillot?= Date: Sat, 7 Feb 2026 08:39:27 -0500 Subject: [PATCH 4/4] chore: Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a4fc65ba..72f360d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - [BREAKING] Removed `hashbrown` dependency and `hashmaps` feature; `Map`/`Set` type aliases are now tied to the `std` feature ([#813](https://github.com/0xMiden/crypto/pull/813)). - [BREAKING] Renamed `NodeIndex::value()` to `NodeIndex::position()`, `NodeIndex::is_value_odd()` to `NodeIndex::is_position_odd()`, and `LeafIndex::value()` to `LeafIndex::position()` ([#814](https://github.com/0xMiden/crypto/pull/814)). - Fixed tuple `min_serialized_size()` to exclude alignment padding, fixing `BudgetedReader` rejecting valid data ([#827](https://github.com/0xMiden/crypto/pull/827)). +- [BREAKING] Fix OOMs in Merkle/SMT deserialization ([#820](https://github.com/0xMiden/crypto/pull/820)). ## 0.22.2 (2026-02-01)