Skip to content

wallet: don't abort on crafted MuSig2 PSBT inputs#35154

Closed
ESultanik wants to merge 1 commit into
bitcoin:masterfrom
trail-of-forks:security/fix-signmusig2-psbt-assert
Closed

wallet: don't abort on crafted MuSig2 PSBT inputs#35154
ESultanik wants to merge 1 commit into
bitcoin:masterfrom
trail-of-forks:security/fix-signmusig2-psbt-assert

Conversation

@ESultanik

@ESultanik ESultanik commented Apr 24, 2026

Copy link
Copy Markdown

Summary

SignMuSig2() in src/script/sign.cpp derives an attacker-supplied aggregate pubkey along an attacker-supplied BIP32 path (both from untrusted PSBT fields PSBT_IN_TAP_BIP32_DERIVATION and PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS) and then Assert()s the result matches the script pubkey. A mismatched derivation or a hardened index aborts bitcoind via finalizepsbt, analyzepsbt, or descriptorprocesspsbt, with no wallet loaded.

Two crash vectors:

  • A hardened index (bit 31 set) in the path hits
    assert((nChild >> 31) == 0) in CPubKey::Derive (pubkey.cpp).
  • A non-hardened path that derives to the wrong pubkey hits
    Assert(XOnlyPubKey(extpub.pubkey) == script_pubkey) at sign.cpp:313.

Assert() uses std::abort() in all builds (NDEBUG is #error'd in check.h), so this terminates the process.

Impact

  • Attack surface: authenticated RPC only; not P2P-reachable.
  • No wallet required: finalizepsbt, analyzepsbt, and descriptorprocesspsbt all reach the vulnerable code via DUMMY_SIGNING_PROVIDER. analyzepsbt makes even a read-only inspection a crash trigger.
  • Gate is trivial: the only precondition is a 4-byte fingerprint match between two attacker-supplied PSBT fields.
  • Affected versions: v31.0rc1, v31.0rc2, and master. Not present in any GA release. Introduced in commit 4a273edda0 (PR wallet: Be able to receive and spend inputs involving MuSig2 aggregate keys #29675). This is release-blocking for v31.0 GA.
  • Severity: DoS — instant node abort. No memory corruption, no key or fund compromise.

Reproduction

On master (2d5ab09f0d at the time of testing):

bitcoind -regtest -disablewallet -daemon
# Variant A — non-hardened path, hits sign.cpp:313 Assert
bitcoin-cli -regtest finalizepsbt "cHNidP8BAF4CAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAAAAAAAAbiCAQAAAAAAIlEgeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5gAAAAAAAEBK6CGAQAAAAAAIlEgeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5ghFnm+Zn753LusVaBilc6HCwcCm/zbLc4o2VnygVsW+BeYCQAGr9RrAAAAAAEXIHm+Zn753LusVaBilc6HCwcCm/zbLc4o2VnygVsW+BeYIhoCxgR/lEHtfW0wRUBulcB82Fx3jkuM7zynq6wJuVxwnuUhAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AAA="
# Variant B — hardened index m/0', hits pubkey.cpp:343 assert
bitcoin-cli -regtest finalizepsbt "cHNidP8BAF4CAAAAAaqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqAAAAAAAAAAAAAbiCAQAAAAAAIlEgeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5gAAAAAAAEBK6CGAQAAAAAAIlEgeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5ghFnm+Zn753LusVaBilc6HCwcCm/zbLc4o2VnygVsW+BeYCQAGr9RrAAAAgAEXIHm+Zn753LusVaBilc6HCwcCm/zbLc4o2VnygVsW+BeYIhoCxgR/lEHtfW0wRUBulcB82Fx3jkuM7zynq6wJuVxwnuUhAvkwigGSWMMQSTRPhfidUim1MchFg2+ZsIYB8RO84Db5AAA="

Both PSBTs pass decodepsbt cleanly and would not look suspicious in a MuSig2 / coinjoin workflow. bitcoind aborts on each call; bitcoin-cli reports "EOF reached". analyzepsbt with the same PSBTs also aborts.

Fix

Two small changes in SignMuSig2() at src/script/sign.cpp:

  1. Reject hardened derivation indices before calling CPubKey::Derive. Public-key derivation only supports non-hardened steps; a hardened index is attacker-controlled input that can never produce a valid derivation, so skipping the aggregate is correct.
  2. Replace the fatal Assert(XOnlyPubKey(extpub.pubkey) == script_pubkey) with if (...) continue;. A pubkey mismatch means this aggregate is not the one that produced script_pubkey. The correct behavior is to move on to the next candidate, not to abort the process.

The diff is 6 added / 1 removed lines.

Credit

Found by Claude during an automated review conducted by Anthropic;
manually validated and patched by Trail of Bits. Reference:
ANT-2026-05771.

Attacker-controlled PSBT fields (PSBT_IN_TAP_BIP32_DERIVATION path and
PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS) flow through a trivially-satisfied
4-byte fingerprint check into CPubKey::Derive() and a fatal Assert().

Two crash vectors exist:
- A hardened derivation index (bit 31 set) in the path hits
  assert((nChild >> 31) == 0) in CPubKey::Derive (pubkey.cpp).
- A mismatched (but unhardened) path derives to the wrong pubkey,
  hitting Assert(XOnlyPubKey(extpub.pubkey) == script_pubkey)
  in SignMuSig2 (sign.cpp).

Both are reachable from any PSBT-processing RPC (finalizepsbt,
analyzepsbt, descriptorprocesspsbt) without a wallet.

Fix: reject hardened indices before entering the derivation loop,
and replace the fatal Assert with a graceful continue that skips
the mismatched aggregate pubkey.
@DrahtBot

DrahtBot commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

The following sections might be updated with supplementary metadata relevant to reviewers and maintainers.

Reviews

See the guideline for information on the review process.
A summary of reviews will appear here.

Conflicts

Reviewers, this pull request conflicts with the following ones:

  • #35011 (ci, iwyu: Fix warnings in src/script and treat them as errors by BrandonOdiwuor)

If you consider this pull request important, please also help to review the conflicting pull requests. Ideally, start with the one that should be merged first.

@ESultanik ESultanik changed the title Don't abort on crafted MuSig2 PSBT inputs cli: don't abort on crafted MuSig2 PSBT inputs Apr 24, 2026
@ESultanik ESultanik changed the title cli: don't abort on crafted MuSig2 PSBT inputs wallet: don't abort on crafted MuSig2 PSBT inputs Apr 24, 2026
@achow101

Copy link
Copy Markdown
Member

This is release-blocking for v31.0 GA.

The release is already out, and this would not be a blocker for it.

Stop calling RPC crashes vulnerabilities.

Comment thread src/script/sign.cpp
continue;
}
// Reject hardened derivation indices (public key derivation only supports unhardened)
if (std::any_of(agg_info.path.begin(), agg_info.path.end(), [](uint32_t idx) { return idx >> 31; })) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of looping through the path again, we can do this check below prior to derivation.

@Sjors

Sjors commented Apr 28, 2026

Copy link
Copy Markdown
Member

Stop calling RPC crashes vulnerabilities.

It would be nice if a PSBT co-signer service doesn't need to restart their node any time a PSBT from an untrusted source does something unexpected. Perhaps we can add fuzz coverage to enforce that CHECK_NONFATAL is hit, rather than Assert.

@achow101

achow101 commented May 7, 2026

Copy link
Copy Markdown
Member

Thanks, but LLM output is not accepted if the author can not properly explain, test or could have written the change themselves.

The bottleneck in this project has always been review and testing, not writing code. Development here is intentionally conservative and slow, and reviewer attention is the scarcest resource we have. LLMs have made this worse, anyone can now prompt them and post their output as PRs. There is an infinite amount plausible looking "improvements" for LLMs to suggest and work on.

Unless we fully trust LLMs to both write and review code, humans still have to spend time understanding the proposed changes, which incurs a non-zero cost for every opened PR.

I understand that contributing to this project can be intimidating, and using LLMs may seem tempting, but it really creates more issues for this project than it solves. The best way to help this project, is to review and test changes. You can use LLMs for this, but you shouldn't solely rely on them, or just post their output.

Please reconsider whether it's something you genuinely think the project should pursue, independent of what your LLM suggested.

I'll close this for now.

@achow101 achow101 closed this May 7, 2026
@ESultanik

Copy link
Copy Markdown
Author

Nowhere in the PR, other than the vestigial branch name, did we claim that this is a security vulnerability. As was stated in the PR, the bug was discovered by Anthropic, but was subsequently manually patched and validated by engineers at Trail of Bits. Would you rather us create a GitHub issue to track the bug?

@maflcko

maflcko commented May 9, 2026

Copy link
Copy Markdown
Member

I've manually converted the two inputs to the fuzz input format and uploaded this to OSS-Fuzz for fun:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants