Merge rust-bitcoin/rust-bitcoin#6915: primitives: Fix `Witness` handling of oversized items
What changed, and why it matters
This commit fixes a bug in how the Rust Bitcoin library counts and compares transaction witness data when a witness contains an oversized item. Previously, several functions relied on an iterator that silently skips oversized items, causing the reported size to be too small, equality checks to wrongly treat different witnesses as equal, and transaction witness IDs (wtxid) to ignore the oversized data. The patch makes these operations use the full stored witness encoding instead. It also fixes a coinbase block validation check so it rejects multi-item coinbase witnesses in constant time rather than collecting and possibly truncating them.
Review and merge this fix; then audit any downstream code that calls `Witness::iter` and assumes it yields all stored elements, especially in validation, signing, or serialization paths. Consider adding documentation that `iter` is capped and that `size`, equality, and hashing must use the full encoding.
Security signals we found
Inconsistent serialization/iterator behavior for oversized witness items
wtxid collision risk between transactions differing only in oversized witness bytes
Incorrect witness equality for oversized single-item stacks
Undercounted witness size could affect fee estimation or size-limited validation
Coinbase witness-commitment validation accepted truncated/oversized multi-item witnesses
Fixes explicitly close multiple audit tracker issues (project-loupe/audit-rust-bitcoin#58, #69, #70, #184, #185)
Evidence from the diff
The Witness type in rust-bitcoin/primitives stores serialized witness data but exposes an iterator (Witness::iter) that stops at items exceeding MAX_WITNESS_ITEM_SIZE. Several consumers trusted that iterator, producing inconsistent behavior for witnesses containing oversized items: Witness::size undercounted because it summed only iterated elements; PartialEq for slices compared only iterated elements, so a one-element oversized witness could compare equal to a different one-element stack; and compute_wtxid hashed only iterated elements, letting two transactions with different oversized witnesses share the same wtxid. The coinbase witness-commitment check collected witness.iter() into a Vec and then tested length, so an invalid multi-item witness could be truncated to one item and accepted. The patch changes size to use the stored serialized length (indices_start), changes slice equality to compare through iterators over both sides, changes wtxid hashing to use hashes::encode_to_engine(&input.witness, &mut enc) so the full encoding is hashed, and checks witness.len() == 1 before reading the reserved value. New tests cover each fixed behavior.
Changed components
primitives/src/witness.rs - Witness::size, Witness PartialEq with slicesprimitives/src/transaction.rs - compute_wtxid / hash_transaction witness hashingprimitives/src/block.rs - coinbase witness commitment validationInspect captured patch +106 / −27
### primitives/src/block.rs
@@ -384,14 +384,20 @@ impl Block<Unchecked> {
if self.transactions[0].is_coinbase() {
let coinbase = &self.transactions[0];
if let Some(commitment) = witness_commitment_from_coinbase(coinbase) {
- // Witness reserved value is in coinbase input witness.
- let witness_vec: Vec<_> = coinbase.inputs[0].witness.iter().collect();
- if witness_vec.len() == 1 && witness_vec[0].len() == 32 {
- if let Some((witness_root, witness_commitment)) =
- self.compute_witness_commitment(witness_vec[0])
- {
- if commitment == witness_commitment {
- return (true, Some(witness_root));
+ // The commitment-bearing coinbase witness must be exactly one 32-byte item.
+ // Check the count first so an invalid multi-item witness is rejected in O(1)
+ // without collecting the items or being truncated by the witness iterator.
+ let witness = &coinbase.inputs[0].witness;
+ if witness.len() == 1 {
+ if let Some(reserved) = witness.get(0) {
+ if reserved.len() == 32 {
+ if let Some((witness_root, witness_commitment)) =
+ self.compute_witness_commitment(reserved)
+ {
+ if commitment == witness_commitment {
+ return (true, Some(witness_root));
+ }
+ }
}
}
}
@@ -2097,6 +2103,41 @@ mod tests {
assert!(matches!(block.validate(), Err(InvalidBlockError::InvalidWitnessCommitment)));
}
+ #[test]
+ #[cfg(feature = "alloc")]
+ fn block_rejects_oversized_trailing_coinbase_witness_item() {
+ let reserved = [11u8; 32];
+ let mut txin = crate::TxIn::EMPTY_COINBASE;
+ txin.witness.push(reserved);
+ // `Witness::iter` stops before an item larger than the decoder cap, hiding this element.
+ txin.witness.push(vec![0u8; 4_000_001]);
+
+ let mut coinbase = Transaction {
+ version: crate::transaction::Version::ONE,
+ lock_time: crate::absolute::LockTime::ZERO,
+ inputs: vec![txin],
+ outputs: vec![],
+ };
+
+ let (_, commitment) = Block::new_unchecked(dummy_header(), vec![coinbase.clone()])
+ .compute_witness_commitment(&reserved)
+ .unwrap();
+ let mut script = Vec::from(WITNESS_COMMITMENT_MAGIC);
+ script.extend_from_slice(commitment.as_byte_array());
+ coinbase.outputs.push(crate::TxOut {
+ amount: units::Amount::MIN,
+ script_pubkey: crate::script::ScriptBuf::from_bytes(script),
+ });
+
+ let transactions = vec![coinbase];
+ let mut header = dummy_header();
+ header.merkle_root = compute_merkle_root(&transactions).unwrap();
+ let block = Block::new_unchecked(header, transactions);
+
+ assert_eq!(block.check_witness_commitment(), (false, None));
+ assert!(matches!(block.validate(), Err(InvalidBlockError::InvalidWitnessCommitment)));
+ }
+
#[test]
#[cfg(feature = "alloc")]
#[cfg(feature = "hex")]
### primitives/src/transaction.rs
@@ -404,12 +404,9 @@ fn hash_transaction(tx: &Transaction, uses_segwit_serialization: bool) -> sha256
if uses_segwit_serialization {
// BIP-0141 (SegWit) transaction serialization also includes the witness data.
for input in &tx.inputs {
- // Same as `Encode for Witness`.
- enc.input(crate::compact_size_encode(input.witness.len()).as_slice());
- for element in &input.witness {
- enc.input(crate::compact_size_encode(element.len()).as_slice());
- enc.input(element);
- }
+ // Hash the full witness encoding. Iterating elements by hand drops any item larger
+ // than `Witness::iter` will yield, which would let differing witnesses share a wtxid.
+ hashes::encode_to_engine(&input.witness, &mut enc);
}
}
@@ -2533,6 +2530,35 @@ mod tests {
assert_eq!(Wtxid::from(tx.clone()), tx.compute_wtxid());
}
+ #[test]
+ #[cfg(feature = "alloc")]
+ fn compute_wtxid_commits_to_oversized_witness_elements() {
+ fn transaction_with_witness_byte(byte: u8) -> Transaction {
+ let witness = Witness::from_slice(&[vec![byte; 4_000_001]]);
+ let input = TxIn {
+ previous_output: OutPoint { txid: Txid::from_byte_array([0x42; 32]), vout: 0 },
+ script_sig: ScriptSigBuf::new(),
+ sequence: Sequence::MAX,
+ witness,
+ };
+
+ Transaction {
+ version: Version::TWO,
+ lock_time: absolute::LockTime::ZERO,
+ inputs: vec![input],
+ outputs: vec![TxOut {
+ amount: Amount::ONE_SAT,
+ script_pubkey: ScriptPubKeyBuf::new(),
+ }],
+ }
+ }
+
+ let first = transaction_with_witness_byte(0x11);
+ let second = transaction_with_witness_byte(0x22);
+
+ assert_ne!(first.compute_wtxid(), second.compute_wtxid());
+ }
+
#[test]
#[cfg(feature = "alloc")]
fn witnesses_encoder_empty_inputs() {
### primitives/src/witness.rs
@@ -198,18 +198,10 @@ impl Witness {
/// assert_eq!(Witness::new().size(), 1); // 1 byte for the '0' encoded as compact size.
/// ```
pub fn size(&self) -> usize {
- let mut size: usize = 0;
-
- size += CompactSizeEncoder::encoded_size(self.witness_elements);
- size += self
- .iter()
- .map(|witness_element| {
- let len = witness_element.len();
- CompactSizeEncoder::encoded_size(len) + len
- })
- .sum::<usize>();
-
- size
+ // `indices_start` is the length of every serialized element, each with its own compact
+ // size length prefix. Using it keeps the count in sync with the encoding even for a
+ // witness holding an item larger than the witness iterator will yield.
+ CompactSizeEncoder::encoded_size(self.witness_elements) + self.indices_start
}
/// Clears the witness.
@@ -601,7 +593,7 @@ impl<T: core::borrow::Borrow<[u8]>> PartialEq<[T]> for Witness {
if self.len() != rhs.len() {
return false;
}
- self.iter().zip(rhs).all(|(left, right)| left == right.borrow())
+ self.iter().eq(rhs.iter().map(<T as core::borrow::Borrow<[u8]>>::borrow))
}
}
@@ -1360,6 +1352,15 @@ mod test {
assert_ne!(witness, rhs.as_slice());
}
+ #[test]
+ #[cfg(feature = "alloc")]
+ fn oversized_element_does_not_compare_equal_to_different_stack() {
+ let oversized = vec![0u8; MAX_WITNESS_ITEM_SIZE + 1];
+ let witness = Witness::from_slice(&[oversized]);
+
+ assert_ne!(witness, [&[0xAAu8][..]]);
+ }
+
#[test]
#[cfg(feature = "serde")]
fn serde_bincode_backward_compatibility() {
@@ -1856,6 +1857,17 @@ mod test {
assert_eq!(witness.size(), encoding::encode_to_vec(&witness).len());
}
+ #[test]
+ #[cfg(feature = "alloc")]
+ fn oversized_public_witness_size_matches_encoding_length() {
+ // Public constructors can hold an oversized item even though Iter::next() rejects it.
+ // size() must not silently omit bytes that Encode will still serialize.
+ let oversized = vec![0u8; MAX_WITNESS_ITEM_SIZE + 1];
+ let witness = Witness::from_slice(&[oversized]);
+
+ assert_eq!(witness.size(), encoding::encode_to_vec(&witness).len());
+ }
+
#[test]
#[cfg(feature = "alloc")]
fn witness_encoder_len_matches_encoding_length() {Why this scored 71/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.