What changed, and why it matters
This commit fixes a Bitcoin signing bug in btcd. When creating a transaction signature of type SIGHASH_SINGLE for an input whose index has no matching output, the signature would not actually commit to any output. That means the signature remains valid even if someone later changes where the money goes, as long as no output exists at that input's index. The patch now refuses to produce such signatures, preventing wallets or automated tools from accidentally creating signatures that could be reused to redirect funds.
Review any code that calls RawTxInSignature, RawTxInWitnessSignature, SignTxOutput, WitnessSignature, or TaprootWitnessSignature with SIGHASH_SINGLE to ensure it handles the new ErrInvalidSigHashSingleIndex error. Upgrade to a release containing this commit and avoid relying on signatures produced under the vulnerable condition, since existing such signatures could still be replayed on-chain.
Security signals we found
SIGHASH_SINGLE without matching output creates signature committing to no output
Signature remains valid if outputs are later mutated, as long as matching output remains absent
Could allow funds redirection by anyone holding the signature
Patch refuses to produce such signatures in legacy and segwit v0 ECDSA signers
Multisig paths skip refused signatures rather than producing them
New error code and tests added for the condition
Evidence from the diff
The change adds a new error code ErrInvalidSigHashSingleIndex and a helper checkSigHashSingle() that rejects signing when (hashType & sigHashMask) == SigHashSingle and idx >= len(tx.TxOut). It is applied to RawTxInSignature (legacy ECDSA) and RawTxInWitnessSignature (segwit v0 ECDSA). Higher-level SignTxOutput paths for P2PKH, P2SH-P2PKH, and bare/P2SH multisig now also refuse or skip these cases. Tests cover P2PKH, bare multisig, P2SH-P2PKH, P2SH-multisig, P2WPKH, and P2TR across all standard hash types for both inputs, plus raw signer edge cases with non-standard hash type values. The commit message and comments explicitly describe the security-relevant behavior: a SIGHASH_SINGLE signature without a matching output commits to no output and would stay valid under arbitrary output mutation.
Changed components
txscript/sign.gotxscript/error.gotxscript/error_test.gotxscript/sign_test.goInspect captured patch +344 / −0
### txscript/error.go
@@ -416,6 +416,15 @@ const (
// non-segwit script.
ErrCodeSeparator
+ // ----------------------------
+ // Failures related to signing.
+ // ----------------------------
+
+ // ErrInvalidSigHashSingleIndex is returned when an attempt is made to
+ // sign an input with the SigHashSingle hash type and an index that is
+ // greater than or equal to the number of outputs.
+ ErrInvalidSigHashSingleIndex
+
// numErrorCodes is the maximum error code number used in tests. This
// entry MUST be the last entry in the enum.
numErrorCodes
@@ -504,6 +513,7 @@ var errorCodeStrings = map[ErrorCode]string{
ErrTaprootMaxSigOps: "ErrTaprootMaxSigOps",
ErrNonConstScriptCode: "ErrNonConstScriptCode",
ErrCodeSeparator: "ErrCodeSeparator",
+ ErrInvalidSigHashSingleIndex: "ErrInvalidSigHashSingleIndex",
}
// String returns the ErrorCode as a human-readable name.
### txscript/error_test.go
@@ -98,6 +98,7 @@ func TestErrorCodeStringer(t *testing.T) {
{ErrTaprootMaxSigOps, "ErrTaprootMaxSigOps"},
{ErrNonConstScriptCode, "ErrNonConstScriptCode"},
{ErrCodeSeparator, "ErrCodeSeparator"},
+ {ErrInvalidSigHashSingleIndex, "ErrInvalidSigHashSingleIndex"},
{0xffff, "Unknown ErrorCode (65535)"},
}
### txscript/sign.go
@@ -6,6 +6,7 @@ package txscript
import (
"errors"
+ "fmt"
"github.com/btcsuite/btcd/address/v2"
"github.com/btcsuite/btcd/btcec/v2"
@@ -15,14 +16,38 @@ import (
"github.com/btcsuite/btcd/wire/v2"
)
+// checkSigHashSingle returns an error if hashType is SigHashSingle and tx has
+// no output at index idx. A signature for such an input commits to no output
+// at all, as legacy inputs sign the constant hash one and segwit v0 inputs
+// zero out hashOutputs. It would stay valid however the outputs are changed
+// while the matching output remains absent, letting anyone holding it
+// redirect the funds, so it must not be produced.
+func checkSigHashSingle(tx *wire.MsgTx, idx int, hashType SigHashType) error {
+ if hashType&sigHashMask != SigHashSingle || idx < len(tx.TxOut) {
+ return nil
+ }
+
+ str := fmt.Sprintf("attempt to sign single input at index %d >= %d "+
+ "outputs", idx, len(tx.TxOut))
+
+ return scriptError(ErrInvalidSigHashSingleIndex, str)
+}
+
// RawTxInWitnessSignature returns the serialized ECDA signature for the input
// idx of the given transaction, with the hashType appended to it. This
// function is identical to RawTxInSignature, however the signature generated
// signs a new sighash digest defined in BIP0143.
+//
+// An ErrInvalidSigHashSingleIndex error is returned if hashType is
+// SigHashSingle and the transaction has no output at index idx.
func RawTxInWitnessSignature(tx *wire.MsgTx, sigHashes *TxSigHashes, idx int,
amt int64, subScript []byte, hashType SigHashType,
key *btcec.PrivateKey) ([]byte, error) {
+ if err := checkSigHashSingle(tx, idx, hashType); err != nil {
+ return nil, err
+ }
+
hash, err := calcWitnessSignatureHashRaw(subScript, sigHashes, hashType, tx,
idx, amt)
if err != nil {
@@ -173,9 +198,16 @@ func RawTxInTapscriptSignature(tx *wire.MsgTx, sigHashes *TxSigHashes, idx int,
// RawTxInSignature returns the serialized ECDSA signature for the input idx of
// the given transaction, with hashType appended to it.
+//
+// An ErrInvalidSigHashSingleIndex error is returned if hashType is
+// SigHashSingle and the transaction has no output at index idx.
func RawTxInSignature(tx *wire.MsgTx, idx int, subScript []byte,
hashType SigHashType, key *btcec.PrivateKey) ([]byte, error) {
+ if err := checkSigHashSingle(tx, idx, hashType); err != nil {
+ return nil, err
+ }
+
hash, err := CalcSignatureHash(subScript, hashType, tx, idx)
if err != nil {
return nil, err
@@ -551,6 +583,12 @@ func (sc ScriptClosure) GetScript(address address.Address) ([]byte, error) {
// will be merged in a type-dependent manner with the newly generated.
// signature script.
//
+// For multisig scripts, keys that are missing or fail to sign are skipped, so
+// an incomplete script can be returned with a nil error. This includes the
+// case where hashType is SigHashSingle and tx has no output at index idx,
+// which pay-to-pubkey and pay-to-pubkey-hash scripts report as
+// ErrInvalidSigHashSingleIndex.
+//
// NOTE: This function is only valid for version 0 scripts. Since the function
// does not accept a script version, the results are undefined for other script
// versions.
### txscript/sign_test.go
@@ -11,6 +11,7 @@ import (
"github.com/btcsuite/btcd/address/v2"
"github.com/btcsuite/btcd/btcec/v2"
+ "github.com/btcsuite/btcd/btcec/v2/ecdsa"
"github.com/btcsuite/btcd/btcec/v2/schnorr"
"github.com/btcsuite/btcd/chaincfg/v2"
"github.com/btcsuite/btcd/chainhash/v2"
@@ -1896,3 +1897,297 @@ func TestRawTxInTapscriptSignature(t *testing.T) {
})
}
}
+
+// TestSignSigHashSingleNoOutput ensures that an input is never signed with
+// SigHashSingle when the transaction has no output at the index of that input.
+// Such a signature commits to no output at all, so it would stay valid however
+// the outputs are changed while the matching output remains absent. All other
+// inputs and hash types must still produce valid signatures.
+func TestSignSigHashSingleNoOutput(t *testing.T) {
+ t.Parallel()
+
+ privKey, err := btcec.NewPrivateKey()
+ require.NoError(t, err)
+
+ pubKey := privKey.PubKey()
+ pubKeyBytes := pubKey.SerializeCompressed()
+
+ p2pkhAddr, err := address.NewAddressPubKeyHash(
+ address.Hash160(pubKeyBytes), &chaincfg.TestNet3Params,
+ )
+ require.NoError(t, err)
+ p2pkhScript, err := PayToAddrScript(p2pkhAddr)
+ require.NoError(t, err)
+
+ pubKeyAddr, err := address.NewAddressPubKey(
+ pubKeyBytes, &chaincfg.TestNet3Params,
+ )
+ require.NoError(t, err)
+ multiSigScript, err := MultiSigScript(
+ []*address.AddressPubKey{pubKeyAddr}, 1,
+ )
+ require.NoError(t, err)
+
+ p2shP2pkhAddr, err := address.NewAddressScriptHash(
+ p2pkhScript, &chaincfg.TestNet3Params,
+ )
+ require.NoError(t, err)
+ p2shP2pkhScript, err := PayToAddrScript(p2shP2pkhAddr)
+ require.NoError(t, err)
+
+ p2shMultiSigAddr, err := address.NewAddressScriptHash(
+ multiSigScript, &chaincfg.TestNet3Params,
+ )
+ require.NoError(t, err)
+ p2shMultiSigScript, err := PayToAddrScript(p2shMultiSigAddr)
+ require.NoError(t, err)
+
+ p2wkhAddr, err := address.NewAddressWitnessPubKeyHash(
+ address.Hash160(pubKeyBytes), &chaincfg.TestNet3Params,
+ )
+ require.NoError(t, err)
+ p2wkhScript, err := PayToAddrScript(p2wkhAddr)
+ require.NoError(t, err)
+
+ p2trScript, err := PayToTaprootScript(
+ ComputeTaprootKeyNoScript(pubKey),
+ )
+ require.NoError(t, err)
+
+ // The transaction has two inputs but a single output, so input 1 has
+ // no output at the same index.
+ const amt = 1e8
+ tx := wire.NewMsgTx(2)
+ tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: 0}})
+ tx.AddTxIn(&wire.TxIn{PreviousOutPoint: wire.OutPoint{Index: 1}})
+ tx.AddTxOut(wire.NewTxOut(amt, p2pkhScript))
+
+ getKey := KeyClosure(func(address.Address) (*btcec.PrivateKey, bool,
+ error) {
+
+ return privKey, true, nil
+ })
+ getScript := mkGetScript(map[string][]byte{
+ p2shP2pkhAddr.EncodeAddress(): p2pkhScript,
+ p2shMultiSigAddr.EncodeAddress(): multiSigScript,
+ })
+
+ // requireSingleIndexErr asserts that err reports an attempt to sign a
+ // SigHashSingle input that has no matching output.
+ requireSingleIndexErr := func(t *testing.T, err error) {
+ t.Helper()
+
+ require.Truef(
+ t, IsErrorCode(err, ErrInvalidSigHashSingleIndex),
+ "unexpected error: %v", err,
+ )
+ }
+
+ // scriptTest is a previous output script to spend. For multisig,
+ // unsigned is the script that SignTxOutput returns when it skips a
+ // refused signature.
+ type scriptTest struct {
+ name string
+ pkScript []byte
+ unsigned []byte
+ }
+
+ // check signs input idx of a copy of tx the way a wallet would for
+ // the type of the script. Without an output to commit to, no signature
+ // may be produced. Otherwise, the signature must validly spend the
+ // input.
+ check := func(t *testing.T, test scriptTest, hashType SigHashType,
+ idx int) {
+
+ pkScript := test.pkScript
+ prevOuts := NewCannedPrevOutputFetcher(pkScript, amt)
+ sigHashes := NewTxSigHashes(tx, prevOuts)
+
+ txCopy := tx.Copy()
+ txIn := txCopy.TxIn[idx]
+ class := GetScriptClass(pkScript)
+
+ var err error
+ switch class {
+ case WitnessV0PubKeyHashTy:
+ txIn.Witness, err = WitnessSignature(
+ txCopy, sigHashes, idx, amt, pkScript,
+ hashType, privKey, true,
+ )
+
+ case WitnessV1TaprootTy:
+ txIn.Witness, err = TaprootWitnessSignature(
+ txCopy, sigHashes, idx, amt, pkScript,
+ hashType, privKey,
+ )
+
+ default:
+ txIn.SignatureScript, err = SignTxOutput(
+ &chaincfg.TestNet3Params, txCopy, idx,
+ pkScript, hashType, getKey, getScript, nil,
+ )
+ }
+
+ noOutput := hashType&sigHashMask == SigHashSingle &&
+ idx >= len(txCopy.TxOut)
+
+ switch {
+ // Every combination except SigHashSingle without a matching
+ // output must produce a valid spend.
+ case !noOutput:
+ require.NoError(t, err)
+
+ vm, err := NewEngine(
+ pkScript, txCopy, idx, StandardVerifyFlags,
+ nil, sigHashes, amt, prevOuts,
+ )
+ require.NoError(t, err)
+ require.NoError(t, vm.Execute())
+
+ // Multisig signing skips keys that fail to sign, so the script
+ // comes back without any signature.
+ case test.unsigned != nil:
+ require.NoError(t, err)
+ require.Equal(t, test.unsigned, txIn.SignatureScript)
+
+ // The taproot sighash itself rejects this combination, with an
+ // error of its own.
+ case class == WitnessV1TaprootTy:
+ require.Error(t, err)
+ require.Nil(t, txIn.Witness)
+
+ default:
+ requireSingleIndexErr(t, err)
+ require.Nil(t, txIn.SignatureScript)
+ require.Nil(t, txIn.Witness)
+ }
+ }
+
+ hashTypes := []SigHashType{
+ SigHashAll,
+ SigHashNone,
+ SigHashSingle,
+ SigHashAll | SigHashAnyOneCanPay,
+ SigHashNone | SigHashAnyOneCanPay,
+ SigHashSingle | SigHashAnyOneCanPay,
+ }
+
+ // The unsigned P2SH-multisig script keeps the redeem script after the
+ // dummy element.
+ unsignedP2shMultiSig, err := NewScriptBuilder().AddOp(OP_0).
+ AddData(multiSigScript).Script()
+ require.NoError(t, err)
+
+ scriptTests := []scriptTest{
+ {name: "p2pkh", pkScript: p2pkhScript},
+ {
+ name: "bare multisig",
+ pkScript: multiSigScript,
+ unsigned: []byte{OP_0},
+ },
+ {name: "p2sh-p2pkh", pkScript: p2shP2pkhScript},
+ {
+ name: "p2sh-multisig",
+ pkScript: p2shMultiSigScript,
+ unsigned: unsignedP2shMultiSig,
+ },
+ {name: "p2wkh", pkScript: p2wkhScript},
+ {name: "p2tr", pkScript: p2trScript},
+ }
+ for _, test := range scriptTests {
+ for _, hashType := range hashTypes {
+ for idx := range tx.TxIn {
+ name := fmt.Sprintf("%s sighash=%#x input=%d",
+ test.name, hashType, idx)
+
+ t.Run(name, func(t *testing.T) {
+ check(t, test, hashType, idx)
+ })
+ }
+ }
+ }
+
+ sigHashes := NewTxSigHashes(
+ tx, NewCannedPrevOutputFetcher(p2wkhScript, amt),
+ )
+
+ // checkRawSig signs input idx with hashType, using the legacy or the
+ // segwit v0 ECDSA signer. Unless refused, the result must be a
+ // signature of the input's sighash, followed by hashType.
+ checkRawSig := func(t *testing.T, witness bool, hashType SigHashType,
+ idx int, refused bool) {
+
+ var (
+ sig, hash []byte
+ err, hashErr error
+ )
+ if witness {
+ sig, err = RawTxInWitnessSignature(
+ tx, sigHashes, idx, amt, p2wkhScript, hashType,
+ privKey,
+ )
+ hash, hashErr = CalcWitnessSigHash(
+ p2wkhScript, sigHashes, hashType, tx, idx, amt,
+ )
+ } else {
+ sig, err = RawTxInSignature(
+ tx, idx, p2pkhScript, hashType, privKey,
+ )
+ hash, hashErr = CalcSignatureHash(
+ p2pkhScript, hashType, tx, idx,
+ )
+ }
+ require.NoError(t, hashErr)
+
+ if refused {
+ requireSingleIndexErr(t, err)
+ require.Nil(t, sig)
+
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotEmpty(t, sig)
+ require.Equal(t, byte(hashType), sig[len(sig)-1])
+
+ parsedSig, err := ecdsa.ParseDERSignature(sig[:len(sig)-1])
+ require.NoError(t, err)
+ require.True(t, parsedSig.Verify(hash, pubKey))
+ }
+
+ // The ECDSA signers mask the hash type with sigHashMask, like the
+ // legacy and segwit v0 sighashes. Without a matching output, they must
+ // refuse every type that the mask turns into SigHashSingle, such as
+ // 0x43, but still sign non-standard types such as 0x07, which the
+ // sighashes treat like SigHashAll and so commit to every output.
+ // Bitcoin Core masks with 3 instead, so without a matching output it
+ // refuses those too.
+ rawTests := []struct {
+ hashType SigHashType
+ idx int
+ refused bool
+ }{
+ {hashType: 0x03, idx: 1, refused: true},
+ {hashType: 0x83, idx: 1, refused: true},
+ {hashType: 0x23, idx: 1, refused: true},
+ {hashType: 0x43, idx: 1, refused: true},
+ {hashType: 0xc3, idx: 1, refused: true},
+ {hashType: 0x07, idx: 1},
+ {hashType: 0x0b, idx: 1},
+ {hashType: 0x13, idx: 1},
+ {hashType: 0x43, idx: 0},
+ }
+ for _, test := range rawTests {
+ for _, witness := range []bool{false, true} {
+ name := fmt.Sprintf("raw witness=%v sighash=%#x "+
+ "input=%d", witness, test.hashType, test.idx)
+
+ t.Run(name, func(t *testing.T) {
+ checkRawSig(
+ t, witness, test.hashType, test.idx,
+ test.refused,
+ )
+ })
+ }
+ }
+}Why this scored 64/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.