What changed, and why it matters
This commit fixes a class of crash bugs in btcd's PSBT (Partially Signed Bitcoin Transaction) handling for Taproot transactions. Previously, if certain Taproot-related lists contained a nil (empty) entry, the code would panic when trying to sort, read, or finalize the transaction. The patch now rejects these malformed entries early with a proper error instead of crashing the program. It is a defensive hardening fix rather than a direct theft-of-funds vulnerability, but a crash in a wallet or node could still be disruptive.
Treat as a defensive security hardening fix. Upgrade to a release containing this commit if your application accepts externally supplied or untrusted PSBTs, especially for Taproot inputs/outputs. Review any custom PSBT construction code to ensure it does not insert nil entries into Taproot slice fields. No immediate incident response is indicated unless crashes have already been observed.
Security signals we found
nil-pointer dereference / panic prevention in Taproot PSBT finalizer
nil-pointer dereference / panic prevention in PSBT serialization
input validation added before sort.Slice and pointer dereference
new test coverage for malformed in-memory PSBT packets
error wrapping with ErrInvalidPsbtFormat for programmatic detection
Evidence from the diff
The patch adds nil-pointer checks in psbt serialization and finalization for Taproot fields: TaprootScriptSpendSig, TaprootLeafScript, and TaprootBip32Derivation on inputs, plus TaprootBip32Derivation on outputs. It also hardens FindLeafScript to reject nil inputs and nil leaf entries. Previously, nil entries in these slices could cause panics during sort.Slice comparisons or direct dereferences in finalizeTaprootInput/isFinalizableWitnessInput. The fix returns wrapped ErrInvalidPsbtFormat errors so callers can detect malformed in-memory packets. Tests are expanded to cover each nil-field path through B64Encode, FindLeafScript, and Finalize/MaybeFinalize.
Changed components
psbt/finalizer.gopsbt/partial_input.gopsbt/partial_output.gopsbt/utils.gopsbt/psbt_test.goInspect captured patch +171 / −16
diff --git a/psbt/finalizer.go b/psbt/finalizer.go
index fa1d8f2..a2a1e2d 100644
--- a/psbt/finalizer.go
+++ b/psbt/finalizer.go
@@ -55,6 +55,10 @@ func isFinalizableWitnessInput(pInput *PInput) bool {
// For each of the script spend signatures we need a
// corresponding tap script leaf with the control block.
for _, sig := range pInput.TaprootScriptSpendSig {
+ if sig == nil {
+ return false
+ }
+
_, err := FindLeafScript(pInput, sig.LeafHash)
if err != nil {
return false
@@ -516,6 +520,13 @@ func finalizeTaprootInput(p *Packet, inIndex int) error {
pInput = &p.Inputs[inIndex]
)
+ for idx, scriptSpendSig := range pInput.TaprootScriptSpendSig {
+ if scriptSpendSig == nil {
+ return fmt.Errorf("nil taproot script spend signature "+
+ "at index %d: %w", idx, ErrInvalidPsbtFormat)
+ }
+ }
+
// What spend path did we take?
switch {
// Key spend path.
@@ -547,8 +558,8 @@ func finalizeTaprootInput(p *Packet, inIndex int) error {
targetLeafHash := pInput.TaprootScriptSpendSig[0].LeafHash
leafScript, err := FindLeafScript(pInput, targetLeafHash)
if err != nil {
- return fmt.Errorf("control block for script spend " +
- "signature not found")
+ return fmt.Errorf("control block for script spend "+
+ "signature not found: %w", err)
}
// The witness stack will contain all signatures, followed by
diff --git a/psbt/partial_input.go b/psbt/partial_input.go
index 9c1c99c..0dc5795 100644
--- a/psbt/partial_input.go
+++ b/psbt/partial_input.go
@@ -495,6 +495,13 @@ func (pi *PInput) serialize(w io.Writer) error {
}
}
+ for idx, scriptSpend := range pi.TaprootScriptSpendSig {
+ if scriptSpend == nil {
+ return fmt.Errorf("nil taproot script spend "+
+ "signature at index %d: %w", idx,
+ ErrInvalidPsbtFormat)
+ }
+ }
sort.Slice(pi.TaprootScriptSpendSig, func(i, j int) bool {
return pi.TaprootScriptSpendSig[i].SortBefore(
pi.TaprootScriptSpendSig[j],
@@ -518,7 +525,8 @@ func (pi *PInput) serialize(w io.Writer) error {
for idx, leafScript := range pi.TaprootLeafScript {
if leafScript == nil {
- return fmt.Errorf("nil taproot leaf script at index %d", idx)
+ return fmt.Errorf("nil taproot leaf script at "+
+ "index %d: %w", idx, ErrInvalidPsbtFormat)
}
}
sort.Slice(pi.TaprootLeafScript, func(i, j int) bool {
@@ -538,6 +546,12 @@ func (pi *PInput) serialize(w io.Writer) error {
}
}
+ for idx, derivation := range pi.TaprootBip32Derivation {
+ if derivation == nil {
+ return fmt.Errorf("nil taproot BIP32 derivation at "+
+ "index %d: %w", idx, ErrInvalidPsbtFormat)
+ }
+ }
sort.Slice(pi.TaprootBip32Derivation, func(i, j int) bool {
return pi.TaprootBip32Derivation[i].SortBefore(
pi.TaprootBip32Derivation[j],
diff --git a/psbt/partial_output.go b/psbt/partial_output.go
index 94b5d33..93233e0 100644
--- a/psbt/partial_output.go
+++ b/psbt/partial_output.go
@@ -2,6 +2,7 @@ package psbt
import (
"bytes"
+ "fmt"
"io"
"sort"
@@ -225,6 +226,12 @@ func (po *POutput) serialize(w io.Writer) error {
}
}
+ for idx, derivation := range po.TaprootBip32Derivation {
+ if derivation == nil {
+ return fmt.Errorf("nil taproot BIP32 derivation at "+
+ "index %d: %w", idx, ErrInvalidPsbtFormat)
+ }
+ }
sort.Slice(po.TaprootBip32Derivation, func(i, j int) bool {
return po.TaprootBip32Derivation[i].SortBefore(
po.TaprootBip32Derivation[j],
diff --git a/psbt/psbt_test.go b/psbt/psbt_test.go
index c2907db..1f36a97 100644
--- a/psbt/psbt_test.go
+++ b/psbt/psbt_test.go
@@ -1338,24 +1338,138 @@ func TestFromUnsigned(t *testing.T) {
}
}
-func TestB64EncodeRejectsNilTaprootLeafScript(t *testing.T) {
- tx := wire.NewMsgTx(2)
- tx.AddTxIn(&wire.TxIn{
- PreviousOutPoint: wire.OutPoint{
- Hash: chainhash.Hash{},
- Index: 0,
+func TestB64EncodeRejectsNilTaprootFields(t *testing.T) {
+ testCases := []struct {
+ name string
+ expectedErr string
+ setNil func(*Packet)
+ }{
+ {
+ name: "input script spend signature",
+ expectedErr: "nil taproot script spend signature at index 0",
+ setNil: func(packet *Packet) {
+ packet.Inputs[0].TaprootScriptSpendSig =
+ []*TaprootScriptSpendSig{nil}
+ },
},
- })
- tx.AddTxOut(wire.NewTxOut(1, []byte{txscript.OP_TRUE}))
+ {
+ name: "input leaf script",
+ expectedErr: "nil taproot leaf script at index 0",
+ setNil: func(packet *Packet) {
+ packet.Inputs[0].TaprootLeafScript =
+ []*TaprootTapLeafScript{nil}
+ },
+ },
+ {
+ name: "input BIP32 derivation",
+ expectedErr: "nil taproot BIP32 derivation at index 0",
+ setNil: func(packet *Packet) {
+ packet.Inputs[0].TaprootBip32Derivation =
+ []*TaprootBip32Derivation{nil}
+ },
+ },
+ {
+ name: "output BIP32 derivation",
+ expectedErr: "nil taproot BIP32 derivation at index 0",
+ setNil: func(packet *Packet) {
+ packet.Outputs[0].TaprootBip32Derivation =
+ []*TaprootBip32Derivation{nil}
+ },
+ },
+ }
- packet, err := NewFromUnsignedTx(tx)
- require.NoError(t, err)
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ tx := wire.NewMsgTx(2)
+ tx.AddTxIn(&wire.TxIn{
+ PreviousOutPoint: wire.OutPoint{},
+ })
+ tx.AddTxOut(wire.NewTxOut(
+ 1, []byte{txscript.OP_TRUE},
+ ))
+
+ packet, err := NewFromUnsignedTx(tx)
+ require.NoError(t, err)
+ testCase.setNil(packet)
- packet.Inputs[0].TaprootLeafScript = []*TaprootTapLeafScript{nil}
- _, err = packet.B64Encode()
+ _, err = packet.B64Encode()
+ require.ErrorIs(t, err, ErrInvalidPsbtFormat)
+ require.ErrorContains(t, err, testCase.expectedErr)
+ })
+ }
+}
+
+func TestFindLeafScriptRejectsNilLeaf(t *testing.T) {
+ _, err := FindLeafScript(nil, make([]byte, chainhash.HashSize))
+ require.ErrorIs(t, err, ErrInvalidPsbtFormat)
+ require.ErrorContains(t, err, "nil PSBT input")
+
+ input := &PInput{
+ TaprootLeafScript: []*TaprootTapLeafScript{nil},
+ }
+
+ _, err = FindLeafScript(input, make([]byte, chainhash.HashSize))
+ require.ErrorIs(t, err, ErrInvalidPsbtFormat)
require.ErrorContains(t, err, "nil taproot leaf script at index 0")
}
+func TestFinalizeRejectsNilTaprootFields(t *testing.T) {
+ newPacket := func() *Packet {
+ tx := wire.NewMsgTx(2)
+ tx.AddTxIn(&wire.TxIn{
+ PreviousOutPoint: wire.OutPoint{},
+ })
+ tx.AddTxOut(wire.NewTxOut(
+ 1, []byte{txscript.OP_TRUE},
+ ))
+
+ packet, err := NewFromUnsignedTx(tx)
+ require.NoError(t, err)
+ packet.Inputs[0].WitnessUtxo = wire.NewTxOut(
+ 1, append(
+ []byte{txscript.OP_1, txscript.OP_DATA_32},
+ make([]byte, 32)...,
+ ),
+ )
+
+ return packet
+ }
+
+ t.Run("script spend signature", func(t *testing.T) {
+ packet := newPacket()
+ packet.Inputs[0].TaprootScriptSpendSig =
+ []*TaprootScriptSpendSig{nil}
+
+ finalized, err := MaybeFinalize(packet, 0)
+ require.False(t, finalized)
+ require.ErrorIs(t, err, ErrNotFinalizable)
+
+ err = Finalize(packet, 0)
+ require.ErrorIs(t, err, ErrInvalidPsbtFormat)
+ require.ErrorContains(
+ t, err, "nil taproot script spend signature at index 0",
+ )
+ })
+
+ t.Run("leaf script", func(t *testing.T) {
+ packet := newPacket()
+ packet.Inputs[0].TaprootScriptSpendSig =
+ []*TaprootScriptSpendSig{{
+ LeafHash: make([]byte, chainhash.HashSize),
+ }}
+ packet.Inputs[0].TaprootLeafScript =
+ []*TaprootTapLeafScript{nil}
+
+ finalized, err := MaybeFinalize(packet, 0)
+ require.False(t, finalized)
+ require.ErrorIs(t, err, ErrNotFinalizable)
+
+ err = Finalize(packet, 0)
+ require.ErrorIs(t, err, ErrInvalidPsbtFormat)
+ require.ErrorContains(t, err, "nil taproot leaf script at index 0")
+ })
+}
+
func TestNonWitnessToWitness(t *testing.T) {
// We'll start with a PSBT produced by Core for which
// the first input is signed and we'll provided the signatures for
diff --git a/psbt/utils.go b/psbt/utils.go
index 2c880e2..6053bca 100644
--- a/psbt/utils.go
+++ b/psbt/utils.go
@@ -464,7 +464,16 @@ func NewFromSignedTx(tx *wire.MsgTx) (*Packet, [][]byte,
func FindLeafScript(pInput *PInput,
targetLeafHash []byte) (*TaprootTapLeafScript, error) {
- for _, leaf := range pInput.TaprootLeafScript {
+ if pInput == nil {
+ return nil, fmt.Errorf("nil PSBT input: %w", ErrInvalidPsbtFormat)
+ }
+
+ for idx, leaf := range pInput.TaprootLeafScript {
+ if leaf == nil {
+ return nil, fmt.Errorf("nil taproot leaf script at index "+
+ "%d: %w", idx, ErrInvalidPsbtFormat)
+ }
+
leafHash := txscript.TapLeaf{
LeafVersion: leaf.LeafVersion,
Script: leaf.Script,
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.