← research

1SEAL-2026-005: signing-path integrity gate bypass via merkle preimage binding break in Ledger Bitcoin App

high
CWE-825 LedgerHQ/app-bitcoin-new fixed in commit 0586ab2 2026-03-17

summary

a stale pointer reuse in call_get_merkle_preimage() in the Ledger Bitcoin app (app-bitcoin-new) causes the function to return bytes that were not bound to the verified merkle hash. the hash verification completes against one set of bytes; the function returns a different set. this breaks the fundamental invariant that merkle-verified data is the same data used by downstream logic.

the binding break enables bypass of call_check_merkle_tree_sorted() — a fail-closed integrity gate in the sign_psbt signing path. the sorting check exists to enforce strict key ordering in PSBT inputs. when bypassed, the signing flow accepts inputs that the security model says should be rejected, enabling duplicate-key equivocation when membership is proven by index.

severity

HIGH (researcher assessment)

Vector: CVSS:3.1/AV:L/AC:H/PR:N/UI:R/S:U/C:N/I:H/A:N

CWE: CWE-825 (Expired Pointer Dereference)

vendor assessment: LOW (inferred from reward tier — EUR 200 vs EUR 750 for a separate MEDIUM-assessed BTC finding in the same program; no CVE issued). the severity reassessment submitted on 2026-02-24 with expanded evidence was not addressed.

severity reasoning

  1. call_get_merkle_preimage() is a security-critical function — it verifies merkle leaf hashes, the mechanism by which the bitcoin app ensures data integrity of transaction components.
  2. the binding break is deterministically reproducible on Speculos (Nano X emulator, Bitcoin app v2.4.2).
  3. the binding break enables bypass of call_check_merkle_tree_sorted() — a fail-closed check in sign_psbt. the PoC demonstrates that the signing flow proceeds past this check with unsorted keys.
  4. an integrity gate bypass in the signing path of a hardware wallet bitcoin app is not LOW. the sorting check exists specifically to prevent equivocation attacks. bypassing it means the signing flow accepts inputs the security model says should be rejected.
  5. hardware wallet context amplifies severity — the device is the sole trust anchor. if its internal integrity checks can be bypassed, the security model fails at its foundation.

affected version

Ledger Bitcoin App (app-bitcoin-new) v2.4.2 and earlier.

tested on commit 2c7956fe566bd7f6f690288130033441fabc5f10 (v2.4.2) and d906a668218395d978bc8d947c54ba830982ac0a.

root cause

the vulnerability is in src/handler/lib/get_merkle_preimage.c, specifically at line 107.

call_get_merkle_preimage() retrieves merkle tree leaf data from the host via a chunked APDU protocol. the function:

  1. sends CCMD_GET_PREIMAGE to request the data
  2. parses the response, caches data_ptr pointing into the APDU read buffer
  3. hashes the data and copies it to the output buffer
  4. if the data exceeds one APDU response (preimage_len >= 253), enters a CCMD_GET_MORE_ELEMENTS loop for remaining chunks

the bug: data_ptr is cached once after parsing the initial CCMD_GET_PREIMAGE response. each subsequent CCMD_GET_MORE_ELEMENTS response resets the read_buffer to a new APDU payload with a different header layout (2-byte header vs the initial response's variable-length header). the hash update reads from the current read_buffer offset correctly, but the copy-out to the caller's buffer uses the stale data_ptr.

when preimage_len >= 253 (triggering a multi-byte varint encoding), the initial data offset differs from the CCMD_GET_MORE_ELEMENTS data offset. the hash covers one set of bytes; the output contains a different set.

Initial APDU:    [header...][varint][data_ptr--> HERE]
GET_MORE APDU:   [2-byte header][data starts HERE]
                                  ^ hash reads from here
                  ^ stale data_ptr still points relative to old layout

impact — binding break

the function's return value — the bytes the caller uses for all downstream logic — can diverge from the bytes that were hashed and verified against the merkle tree. this breaks the binding between "verified bytes" and "returned bytes."

any caller that trusts the returned bytes to be merkle-verified is operating on unverified data.

impact — signing-path integrity gate bypass

call_check_merkle_tree_sorted() in the sign_psbt flow calls call_get_merkle_preimage() to retrieve key preimages and verify they are in sorted order. this is a fail-closed security check — if keys are not sorted, the signing flow must abort.

because of the binding break, the merkle tree can commit to an unsorted pair of key preimages, but the sorting logic receives diverging bytes and accepts them as sorted. the signing flow proceeds past the integrity gate.

this was demonstrated on Speculos: the PoC commits unsorted keys in the merkle tree, and the signing flow proceeds past the sort check (confirmed by observing the subsequent client command that occurs only after the in-app sort check succeeds).

the secondary impact: bypassing strict key ordering enables duplicate-key equivocation when membership is proven by index rather than by uniqueness proof. CCMD_GET_MERKLE_LEAF_INDEX proves that a key exists at a specific index in the tree — but if the tree can contain duplicate keys (because the sorting check is bypassed), the index-based membership proof is no longer unique.

proof of concept

two PoC implementations were provided to Ledger:

1. deterministic local simulation — demonstrates the binding break in isolation. markers: CALLSITE_HIT (confirms the vulnerable code path is reached), PROOF_MARKER (output_matches_hashed_bytes=false), NC_MARKER (control run, output_matches_hashed_bytes=true).

2. end-to-end Speculos PoC (poc_speculos.py) — targets Bitcoin app v2.4.2 on Nano X emulator. two modes:

  • --mode exploit: commits unsorted keys in merkle tree, demonstrates that the signing flow proceeds past call_check_merkle_tree_sorted() (post_check_sorted_client_command_seen=true)
  • --mode control: same flow with correctly sorted keys, demonstrating correct behavior

fix

the fix refreshes data_ptr inside the CCMD_GET_MORE_ELEMENTS loop, ensuring the copy-out source always corresponds to the current read_buffer contents — the same bytes that were hashed.

this is a one-liner fix. see commit 0586ab2.

users should update to the latest Bitcoin app version through Ledger Live.

timeline

date event
2026-01-30 reported to Ledger Donjon with deterministic local simulation PoC, self-assessed as MEDIUM
2026-02-04 Ledger acknowledges: "the code behavior you pointed out appears to be valid"
2026-02-04 Ledger requests Speculos-based end-to-end PoC
2026-02-12 researcher provides Speculos PoC (Bitcoin app v2.4.2, exploit + control modes)
2026-02-24 researcher submits severity reassessment with expanded evidence demonstrating signing-path integrity gate bypass
2026-03-06 Ledger merges fix (commit 0586ab2) in public repository
2026-03-13 Ledger offers EUR 200 reward (LOW tier). severity reassessment not referenced
2026-03-15 researcher declines reward; no agreement signed, no NDA
2026-03-17 public advisory

credit

discovered and reported by Oleh Konko (@1seal) — https://github.com/1seal

user guidance

the fix for this specific finding has been merged into the app-bitcoin-new repository. users should ensure they are running the latest Bitcoin app version through Ledger Live.

the underlying pattern — stale pointer reuse across chunked APDU responses — may affect other parsing paths that use the same CCMD_GET_MORE_ELEMENTS protocol. users should keep all Ledger apps updated.

references