What changed, and why it matters
This change tightens how a Bitcoin PSBT library extracts final witness data when turning a partially-signed transaction into a finished transaction. Previously, extra bytes after the declared witness stack were silently ignored. Now the library rejects such packets. That prevents malformed or crafted PSBTs from sneaking hidden data into the witness field, which could in some scenarios alter transaction behavior or be used to confuse downstream tools.
Review whether assertFullyConsumed is also needed on other deserialized PSBT fields (e.g., final scriptsig, taproot fields) and add regression tests with trailing-byte PSBTs. Consider a security advisory if malformed PSBTs could have caused incorrect extracted transactions in deployed wallets or services.
Security signals we found
strict parsing of serialized witness data
rejection of trailing bytes in PSBT final script witness
potential malleability / ambiguity reduction in PSBT extraction
Evidence from the diff
In psbt/extractor.go, the witness deserialization loop reads a varint count followed by that many varint-prefixed witness items from PSBT_IN_FINAL_SCRIPTWITNESS. Before the patch, any trailing bytes left in witnessReader after the loop were ignored. The patch adds assertFullyConsumed(witnessReader) after the loop and returns an error if bytes remain. This enforces that the final script witness field contains exactly one serialized witness stack with no trailing data.
Changed components
btcd/psbt/extractor.goPSBT extraction pathPSBT_IN_FINAL_SCRIPTWITNESS handlingInspect captured patch +13 / −9
diff --git a/psbt/extractor.go b/psbt/extractor.go
index cf7e7b6..6d2d00f 100644
--- a/psbt/extractor.go
+++ b/psbt/extractor.go
@@ -46,24 +46,24 @@ func Extract(p *Packet) (*wire.MsgTx, error) {
// to extract that as well, parsing the lower-level transaction
// encoding.
if pInput.FinalScriptWitness != nil {
- // In order to set the witness, need to re-deserialize
- // the field as encoded within the PSBT packet. For
- // each input, the witness is encoded as a stack with
- // one or more items.
+ // PSBT_IN_FINAL_SCRIPTWITNESS stores the complete
+ // scriptWitness for this transaction input. Decode that
+ // single serialized witness stack into TxWitness below.
witnessReader := bytes.NewReader(
pInput.FinalScriptWitness,
)
- // First we extract the number of witness elements
- // encoded in the above witnessReader.
+ // First extract the number of witness items encoded in
+ // the serialized scriptWitness.
witCount, err := wire.ReadVarInt(witnessReader, 0)
if err != nil {
return nil, err
}
- // Now that we know how many inputs we'll need, we'll
- // construct a packing slice, then read out each input
- // (with a varint prefix) from the witnessReader.
+ // Allocate one slot per witness item, then read each
+ // varint-prefixed item from the witness value. The value
+ // must contain exactly this one stack, so the exhaustion
+ // check below rejects trailing bytes.
tin.Witness = make(wire.TxWitness, witCount)
for j := uint64(0); j < witCount; j++ {
wit, err := wire.ReadVarBytes(
@@ -75,6 +75,10 @@ func Extract(p *Packet) (*wire.MsgTx, error) {
}
tin.Witness[j] = wit
}
+
+ if err := assertFullyConsumed(witnessReader); err != nil {
+ return nil, err
+ }
}
}
Why this scored 58/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.