1SEAL-2026-005: signing-path integrity gate bypass via merkle preimage binding break in Ledger Bitcoin App
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
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.- the binding break is deterministically reproducible on Speculos (Nano X emulator, Bitcoin app v2.4.2).
- the binding break enables bypass of
call_check_merkle_tree_sorted()— a fail-closed check insign_psbt. the PoC demonstrates that the signing flow proceeds past this check with unsorted keys. - 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.
- 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:
- sends
CCMD_GET_PREIMAGEto request the data - parses the response, caches
data_ptrpointing into the APDU read buffer - hashes the data and copies it to the output buffer
- if the data exceeds one APDU response (
preimage_len >= 253), enters aCCMD_GET_MORE_ELEMENTSloop 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 pastcall_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.