psbt: Add bounds check for non-witness UTXO output index
What changed, and why it matters
This commit fixes a validation gap in how the library handles Partially Signed Bitcoin Transactions (PSBTs). A PSBT can include a reference to a previous transaction output (the 'non-witness UTXO') that doesn't actually exist. Before this fix, the library would not reject this, which could allow malformed or malicious PSBTs to be processed. The fix adds a bounds check so that if a PSBT input claims an output index that is too high, deserialization returns a clear error instead of silently continuing. The issue was found by differential fuzzing against Bitcoin Core.
Review downstream uses of PSBT deserialization to ensure malformed PSBTs are not being accepted due to the missing bounds check in prior versions. Consider whether this fix should be backported to supported release branches, as it changes deserialization behavior to reject previously accepted invalid PSBTs.
Security signals we found
Bounds check added for PSBT non-witness UTXO output index
New error variant provides detailed context for out-of-bounds references
Aligns behavior with Bitcoin Core PSBT deserialization
Found by differential fuzzing using bitcoinfuzz
Test vector sourced from Bitcoin Core commit 9e13ccc50eec9d2efe0f472e6d50dc822df70d84
Evidence from the diff
The patch adds a new NonWitnessUtxoOutOfBounds error variant and a check in Psbt::deserialize (bitcoin/src/psbt/serialize.rs) that verifies input_outpoint.vout < tx.outputs.len() before checking the non-witness UTXO txid. It also updates test vectors and serde regression data to use a valid vout (0 instead of 1) for the referenced non-witness UTXO, and adds a Bitcoin Core-derived test case that exercises the new error path with an out-of-bounds vout of 33554432.
Changed components
bitcoin/src/psbt/serialize.rsbitcoin/src/psbt/error.rsbitcoin/src/psbt/mod.rsbitcoin/tests/serde.rsInspect captured patch +44 / −3
diff --git a/bitcoin/src/psbt/error.rs b/bitcoin/src/psbt/error.rs
index 3a62df06..5e178c75 100644
--- a/bitcoin/src/psbt/error.rs
+++ b/bitcoin/src/psbt/error.rs
@@ -90,6 +90,16 @@ pub enum Error {
/// The [`Txid`] of the non-witness UTXO.
non_witness_utxo_txid: Txid,
},
+ /// Non-witness UTXO does not have enough outputs for the `vout` specified
+ /// in the transaction input.
+ NonWitnessUtxoOutOfBounds {
+ /// The index of the input in question.
+ index: usize,
+ /// The vout of the input, as it appears in the unsigned transaction.
+ vout: u32,
+ /// The number of outputs in the non-witness UTXO.
+ non_witness_utxo_output_count: usize,
+ },
/// Parsing error indicating invalid public keys
InvalidPublicKey(key::FromSliceError),
/// Parsing error indicating invalid secp256k1 public keys
@@ -171,6 +181,13 @@ impl fmt::Display for Error {
non_witness_utxo_txid, index, input_outpoint
)
}
+ Self::NonWitnessUtxoOutOfBounds { index, vout, non_witness_utxo_output_count } => {
+ write!(
+ f,
+ "input {} references vout {}, but non-witness UTXO only has {} outputs",
+ index, vout, non_witness_utxo_output_count
+ )
+ }
Self::InvalidPublicKey(ref e) => write_err!(f, "invalid public key"; e),
Self::InvalidSecp256k1PublicKey(ref e) =>
write_err!(f, "invalid secp256k1 public key"; e),
@@ -217,6 +234,7 @@ impl std::error::Error for Error {
| Self::NegativeFee
| Self::FeeOverflow
| Self::IncorrectNonWitnessUtxo { .. }
+ | Self::NonWitnessUtxoOutOfBounds { .. }
| Self::InvalidPublicKey(_)
| Self::InvalidSecp256k1PublicKey(_)
| Self::InvalidXOnlyPublicKey
diff --git a/bitcoin/src/psbt/mod.rs b/bitcoin/src/psbt/mod.rs
index 71f01844..ad58ab14 100644
--- a/bitcoin/src/psbt/mod.rs
+++ b/bitcoin/src/psbt/mod.rs
@@ -1584,7 +1584,7 @@ mod tests {
txid: "e567952fb6cc33857f392efa3a46c995a28f69cca4bb1b37e0204dab1ec7a389"
.parse()
.unwrap(),
- vout: 1,
+ vout: 0,
},
script_sig: ScriptSigBuf::from_hex_no_length_prefix(
"160014be18d152a9b012039daf3da7de4f53349eecb985",
@@ -2104,6 +2104,21 @@ mod tests {
}
}
+ // Test vector from Bitcoin Core.
+ // https://github.com/bitcoin/bitcoin/commit/9e13ccc50eec9d2efe0f472e6d50dc822df70d84
+ #[test]
+ fn non_witness_utxo_vout_out_of_bounds() {
+ let err = hex_psbt("70736274ff0100750200000001268171371edff285e937adeea4b37b78000c0566cbb3ad64641713ca42171bf60000000200feffffff02d3dff505000000001976a914d0c59903c5bac2868760e90fd521a4665aa7652088ac00e1f5050000000017a9143545e6e33b832c47050f24d3eeb93c9c03948bc787b32e1300000100fda5010100000000010289a3c71eab4d20e0371bbba4cc698fa295c9463afa2e397f8533ccb62f9567e50100000017160014be18d152a9b012039daf3da7de4f53349eecb985ffffffff86f8aa43a71dff1448893a530a7237ef6b4608bbb2dd2d0171e63aec6a4890b40100000017160014fe3e9ef1a745e974d902c4355943abcb34bd5353ffffffff0200c2eb0b000000001976a91485cff1097fd9e008bb34af709c62197b38978a4888ac72fef84e2c00000017a914339725ba21efd62ac753a9bcd067d6c7a6a39d05870247304402202712be22e0270f394f568311dc7ca9a68970b8025fdd3b240229f07f8a5f3a240220018b38d7dcd314e734c9276bd6fb40f673325bc4baa144c800d2f2f02db2765c012103d2e15674941bad4a996372cb87e1856d3652606d98562fe39c5e9e7e413f210502483045022100d12b852d85dcd961d2f5f4ab660654df6eedcc794c0c33ce5cc309ffb5fce58d022067338a8e0e1725c197fb1a88af59f51e44e4255b20167c8684031c05d1f2592a01210223b72beef0965d10be0778efecd61fcac6f79a4ea169393380734464f84f2ab300000000000000").unwrap_err();
+ match err {
+ Error::NonWitnessUtxoOutOfBounds { index, vout, non_witness_utxo_output_count } => {
+ assert_eq!(index, 0);
+ assert_eq!(vout, 33554432);
+ assert_eq!(non_witness_utxo_output_count, 2);
+ }
+ _ => panic!("expected NonWitnessUtxoOutOfBounds error, got: {}", err),
+ }
+ }
+
#[test]
fn serialize_and_deserialize_preimage_psbt() {
// create a sha preimage map
diff --git a/bitcoin/src/psbt/serialize.rs b/bitcoin/src/psbt/serialize.rs
index 6f393577..502dc007 100644
--- a/bitcoin/src/psbt/serialize.rs
+++ b/bitcoin/src/psbt/serialize.rs
@@ -108,6 +108,13 @@ impl Psbt {
if let Some(ref tx) = input.non_witness_utxo {
let input_outpoint = global.unsigned_tx.inputs[i].previous_output;
let txid = tx.compute_txid();
+ if input_outpoint.vout >= tx.outputs.len() as u32 {
+ return Err(Error::NonWitnessUtxoOutOfBounds {
+ index: i,
+ vout: input_outpoint.vout,
+ non_witness_utxo_output_count: tx.outputs.len(),
+ });
+ }
if txid != input_outpoint.txid {
return Err(Error::IncorrectNonWitnessUtxo {
index: i,
diff --git a/bitcoin/tests/data/serde/psbt_base64.json b/bitcoin/tests/data/serde/psbt_base64.json
index d0515517..67789035 100644
--- a/bitcoin/tests/data/serde/psbt_base64.json
+++ b/bitcoin/tests/data/serde/psbt_base64.json
@@ -1 +1 @@
-"cHNidP8BAFMBAAAAATkUkZZWjQ4TAMqaOkez2dl2+5yBsfd38qS6x8fkjesmAQAAAAD/////AXL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHAAAAAE8BBIiyHgAAAAAAAAAAAIc9/4HAL1JWI/0f5RZ+rDpVoEnePTFLtC7iJ//tN9UIAzmjYBMwFZfa70H75ZOgLMUT0LVVJ+wt8QUOLo/0nIXCDN6tvu8AAACAAQAAABD8BXByZWZ4KnRlc3Rfa2V5AwUGBwMJAAEDAwQFAAEAjwEAAAAAAQGJo8ceq00g4Dcbu6TMaY+ilclGOvouOX+FM8y2L5Vn5QEAAAAXFgAUvhjRUqmwEgOdrz2n3k9TNJ7suYX/////AXL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHASED0uFWdJQbrUqZY3LLh+GFbTZSYG2YVi/jnF6efkE/IQUAAAAAAQEgcv74TiwAAAAXqRQzlyW6Ie/WKsdTqbzQZ9bHpqOdBYciAgM5iA3JI5S3NV49BDn6KDwx3nWQgS6gEcQkXAZ0poXog0cwRAIgT2fir7dhQtRPrliiSV0zo0GdqibNDbjQTzRStjKJrA8CIBB2Kp+2fpTMXK2QJvbcmf9/Bw9CeNMPvH0Mhp3TjH/nAQEDBIMAAAABBAFRIgYDOYgNySOUtzVePQQ5+ig8Md51kIEuoBHEJFwGdKaF6IMM3q2+7wAAAIABAAAAAQgGAgIBAwEFFQoYn3yLGjhv/o7tkbODDHp7zR53jAIBAiELoShx/uIQ+4YZKR6uoZRYHL0lMeSyN1nSJfaAaSP2MiICAQIVDBXMSeGRy8Ug2RlEYApct3r2qjKRAgECIQ12pWrO2RXSUT3NhMLDeLLoqlzWMrW3HKLyrFsOOmSb2wIBAhD8BXByZWZ4KnRlc3Rfa2V5AwUGBwMJAAEDAwQFACICAzmIDckjlLc1Xj0EOfooPDHedZCBLqARxCRcBnSmheiDDN6tvu8AAACAAQAAABD8BXByZWZ4KnRlc3Rfa2V5AwUGBwMJAAEDAwQFAA=="
\ No newline at end of file
+"cHNidP8BAFMBAAAAAbGeVF5WH6GP8XoakKWk+Gr28METMZERHm20gVQfhsueAAAAAAD/////AXL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHAAAAAE8BBIiyHgAAAAAAAAAAAIc9/4HAL1JWI/0f5RZ+rDpVoEnePTFLtC7iJ//tN9UIAzmjYBMwFZfa70H75ZOgLMUT0LVVJ+wt8QUOLo/0nIXCDN6tvu8AAACAAQAAABD8BXByZWZ4KnRlc3Rfa2V5AwUGBwMJAAEDAwQFAAEAjwEAAAAAAQGJo8ceq00g4Dcbu6TMaY+ilclGOvouOX+FM8y2L5Vn5QAAAAAXFgAUvhjRUqmwEgOdrz2n3k9TNJ7suYX/////AXL++E4sAAAAF6kUM5cluiHv1irHU6m80GfWx6ajnQWHASED0uFWdJQbrUqZY3LLh+GFbTZSYG2YVi/jnF6efkE/IQUAAAAAAQEgcv74TiwAAAAXqRQzlyW6Ie/WKsdTqbzQZ9bHpqOdBYciAgM5iA3JI5S3NV49BDn6KDwx3nWQgS6gEcQkXAZ0poXog0cwRAIgT2fir7dhQtRPrliiSV0zo0GdqibNDbjQTzRStjKJrA8CIBB2Kp+2fpTMXK2QJvbcmf9/Bw9CeNMPvH0Mhp3TjH/nAQEDBIMAAAABBAFRIgYDOYgNySOUtzVePQQ5+ig8Md51kIEuoBHEJFwGdKaF6IMM3q2+7wAAAIABAAAAAQgGAgIBAwEFFQoYn3yLGjhv/o7tkbODDHp7zR53jAIBAiELoShx/uIQ+4YZKR6uoZRYHL0lMeSyN1nSJfaAaSP2MiICAQIVDBXMSeGRy8Ug2RlEYApct3r2qjKRAgECIQ12pWrO2RXSUT3NhMLDeLLoqlzWMrW3HKLyrFsOOmSb2wIBAhD8BXByZWZ4KnRlc3Rfa2V5AwUGBwMJAAEDAwQFACICAzmIDckjlLc1Xj0EOfooPDHedZCBLqARxCRcBnSmheiDDN6tvu8AAACAAQAAABD8BXByZWZ4KnRlc3Rfa2V5AwUGBwMJAAEDAwQFAA=="
\ No newline at end of file
diff --git a/bitcoin/tests/data/serde/psbt_bincode b/bitcoin/tests/data/serde/psbt_bincode
index 0423835c..a5c08bed 100644
Binary files a/bitcoin/tests/data/serde/psbt_bincode and b/bitcoin/tests/data/serde/psbt_bincode differ
diff --git a/bitcoin/tests/serde.rs b/bitcoin/tests/serde.rs
index 213555a3..ea4bf1d8 100644
--- a/bitcoin/tests/serde.rs
+++ b/bitcoin/tests/serde.rs
@@ -202,7 +202,7 @@ fn serde_regression_psbt() {
txid: "e567952fb6cc33857f392efa3a46c995a28f69cca4bb1b37e0204dab1ec7a389"
.parse::<Txid>()
.unwrap(),
- vout: 1,
+ vout: 0,
},
script_sig: ScriptSigBuf::from_hex_no_length_prefix(
"160014be18d152a9b012039daf3da7de4f53349eecb985",
@@ -298,6 +298,7 @@ fn serde_regression_psbt() {
Psbt::deserialize(&serialized).unwrap();
let got = serialize(&psbt).unwrap();
+
let want = include_bytes!("data/serde/psbt_bincode") as &[_];
assert_eq!(got, want);
Why this scored 60/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.