Merge bitcoin/bitcoin#35933: psbt: don't abort on invalid MuSig2 derivations
What changed, and why it matters
This update fixes a crash bug in Bitcoin Core's handling of certain partially-signed Bitcoin transactions (PSBTs). A malformed MuSig2 derivation path could previously trigger an internal abort (assertion failure or hard crash), even through a read-only RPC call like analyzepsbt. The fix turns those crashes into ordinary failures, so an attacker can no longer force a co-signer node to restart by submitting a bad PSBT.
Apply the merge commit. The fix is minimal and well-tested; nodes that process untrusted PSBTs (especially co-signer or watch-only services exposing analyzepsbt/finalizepsbt) should upgrade promptly to avoid remote-triggered crashes.
Security signals we found
Denial-of-service via crafted PSBT input
Assertion abort reachable through read-only RPC (analyzepsbt)
MuSig2 public-key derivation path not validated before use
Hardened derivation index triggers abort from public xpub
Mismatched derived key triggered assert rather than graceful failure
Evidence from the diff
In src/script/sign.cpp, SignMuSig2 previously used a signed int loop variable for agg_info.path and called Assert(XOnlyPubKey(extpub.pubkey) == script_pubkey). A hardened child index (high bit set) caused CExtPubKey::Derive to abort via an internal assertion, and a path that derived to a non-matching key also aborted at the Assert. The patch changes the loop variable to uint32_t, explicitly returns false on hardened indices, and replaces the assertion with a continue so only the non-matching aggregate is skipped. A functional test is added to confirm analyzepsbt and finalizepsbt now fail gracefully instead of crashing.
Changed components
src/script/sign.cpp (SignMuSig2)RPC analyzepsbtRPC finalizepsbtRPC descriptorprocesspsbtMuSig2 PSBT handlingInspect captured patch +28 / −4
### src/script/sign.cpp
@@ -318,14 +318,15 @@ static bool SignMuSig2(const BaseSignatureCreator& creator, SignatureData& sigda
}
// Get the BIP32 derivation tweaks
CExtPubKey extpub = CreateMuSig2SyntheticXpub(agg_pub);
- for (const int i : agg_info.path) {
+ for (const uint32_t i : agg_info.path) {
+ if (i >> 31) return false; // Hardened derivation is not possible from a public key
auto& [t, xonly] = tweaks.emplace_back();
xonly = false;
if (!extpub.Derive(extpub, i, &t)) {
return false;
}
}
- Assert(XOnlyPubKey(extpub.pubkey) == script_pubkey);
+ if (XOnlyPubKey(extpub.pubkey) != script_pubkey) continue;
plain_pub = extpub.pubkey;
}
### test/functional/rpc_psbt.py
@@ -13,6 +13,7 @@
MAX_STANDARD_TX_WEIGHT,
)
from test_framework.descriptors import descsum_create
+from test_framework.extendedkey import hardened
from test_framework.key import H_POINT
from test_framework.messages import (
COutPoint,
@@ -40,15 +41,17 @@
PSBT_IN_MUSIG2_PUB_NONCE,
PSBT_IN_NON_WITNESS_UTXO,
PSBT_IN_PROPRIETARY,
+ PSBT_IN_TAP_BIP32_DERIVATION,
+ PSBT_IN_TAP_INTERNAL_KEY,
PSBT_IN_WITNESS_UTXO,
PSBT_IN_FINAL_SCRIPTWITNESS,
PSBT_OUT_MUSIG2_PARTICIPANT_PUBKEYS,
PSBT_OUT_PROPRIETARY,
PSBT_OUT_TAP_TREE,
PSBT_OUT_SCRIPT,
)
-from test_framework.script import CScript, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY
-from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE
+from test_framework.script import CScript, OP_TRUE, SIGHASH_ALL, SIGHASH_ANYONECANPAY, hash160
+from test_framework.script_util import MIN_STANDARD_TX_NONWITNESS_SIZE, output_key_to_p2tr_script
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_not_equal,
@@ -302,6 +305,25 @@ def test_decodepsbt_musig2_input_output_types(self):
assert "participant_pubkeys" in out_participant_pks
assert_equal(out_participant_pks["participant_pubkeys"], [out_pubkey1.hex(), out_pubkey2.hex()])
+ def test_musig2_untrusted_derivation(self):
+ self.log.info("Test MuSig2 aggregate derivation from untrusted PSBT fields")
+ node = self.nodes[0]
+
+ script_pubkey = bytes.fromhex(H_POINT)
+ _, aggregate_pubkey = generate_keypair()
+ _, participant_pubkey = generate_keypair()
+
+ # Both have a matching aggregate fingerprint but cannot derive the script pubkey: 0 derives a different key, hardened(0) cannot be derived at all
+ for index in [0, hardened(0)]:
+ psbt = self.create_psbt(inputs={
+ PSBT_IN_WITNESS_UTXO: CTxOut(nValue=1, scriptPubKey=output_key_to_p2tr_script(script_pubkey)).serialize(),
+ bytes([PSBT_IN_TAP_BIP32_DERIVATION]) + script_pubkey: ser_compact_size(0) + hash160(aggregate_pubkey)[:4] + index.to_bytes(4, "little"),
+ PSBT_IN_TAP_INTERNAL_KEY: script_pubkey,
+ bytes([PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS]) + aggregate_pubkey: [participant_pubkey],
+ }).to_base64()
+ assert_equal(node.analyzepsbt(psbt)["inputs"][0]["is_final"], False)
+ assert_equal(node.finalizepsbt(psbt)["complete"], False)
+
def test_combinepsbt_preserves_proprietary_fields(self):
self.log.info("Test that combining PSBTs preserves proprietary fields")
@@ -1500,6 +1522,7 @@ def test_psbt_input_keys(psbt_input, keys):
self.test_psbt_roundtrip()
self.test_psbt_version()
self.test_psbt_with_invalid_signature()
+ self.test_musig2_untrusted_derivation()
if __name__ == '__main__':
PSBTTest(__file__).main()Why this scored 78/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.