fix: harden parsing and signing against malformed input
What changed, and why it matters
This commit fixes several low-level programming bugs in Ledger's Bitcoin app that were found by automated fuzzing. The bugs include a dangerous bit shift, a pointless memory move with zero bytes, and incorrect comparisons between signed and unsigned numbers when parsing transaction data and signing PSBTs. Most fixes are defensive hardening, but one change moves a sanity check earlier so the app rejects absurdly large input amounts before adding them up, which could prevent an overflow. The commit does not claim these are directly exploitable attacks, but they remove undefined behavior that could in theory lead to crashes or incorrect signing.
Treat as a security-hardening fix and include in the next release. Review whether the moved BITCOIN_TOTAL_SUPPLY check fully prevents all overflow paths in inputs_total_amount accumulation, and continue fuzzing the affected parsers/signers. No immediate CVE is required unless a reproducible exploit is demonstrated.
Security signals we found
Undefined behavior fixes (signed left shift, signed/unsigned comparison, out-of-range integer conversion)
Fuzzing harness found issues
Input amount overflow hardening moved before accumulation
Merkle tree traversal mask hardening
Memory operation zero-length guard
Evidence from the diff
The patch hardens parsing and signing code against malformed input discovered by a fuzzing harness. Key changes: (1) merkle.c uses unsigned 1U << (depth-1) to avoid signed left-shift overflow when depth reaches 32; (2) merkle.h caps ceil_lg loop at r < 32 to prevent unbounded shift; (3) parser_ext.c skips memmove when length1 == 0; (4) check_merkle_tree_sorted.c and policy.c add explicit uint32_t casts to silence sign-change/width warnings; (5) sign_psbt.h changes address_index from int to uint32_t; (6) musig_signing.c compares call_get_merkleized_map_value’s int return against a cast (int)sizeof to avoid signed/unsigned comparison; (7) preprocess_inputs.c moves/repeats a BITCOIN_TOTAL_SUPPLY check before accumulating prevout amounts to prevent overflow of inputs_total_amount. The fuzzer ignore-list is also expanded for known benign function-pointer casts.
Changed components
src/common/merkle.csrc/common/merkle.hsrc/common/parser_ext.csrc/handler/lib/check_merkle_tree_sorted.csrc/handler/lib/policy.csrc/handler/sign_psbt.hsrc/handler/sign_psbt/musig_signing.csrc/handler/sign_psbt/preprocess_inputs.cInspect captured patch +43 / −23
### fuzzing/sanitizers/ubsan-ignorelist.txt
@@ -7,8 +7,17 @@
[function]
# parsing_step_t and merkle_tree_elements_callback_t are generic function-pointer
# typedefs that the dispatcher casts typed callbacks through deliberately. The
-# idiom trips -fsanitize=function without being a defect. Scoped to the two files
-# that own the idiom rather than the whole app.
+# idiom trips -fsanitize=function without being a defect. Scoped to the files that
+# own the idiom rather than the whole app.
src:*/src/handler/lib/stream_merkleized_map_value.c
src:*/src/handler/lib/parser.c
src:*/common/parser_ext.c
+src:*/src/handler/sign_psbt/preprocess_inputs.c
+src:*/src/handler/sign_psbt/preprocess_outputs.c
+src:*/src/handler/sign_psbt/sign_input.c
+
+# send_response() carries an empty parameter list, so its type is not void(void)
+# and reaching it through dispatcher_context_t's `void (*send_response)(void)` is
+# a call through an incompatible type. Scoped to the function so the dispatcher
+# keeps the oracle everywhere else.
+fun:send_response
### src/common/merkle.c
@@ -66,8 +66,8 @@ int merkle_get_ith_direction(size_t size, size_t index, size_t i) {
uint8_t depth = ceil_lg(size);
// bitmask of the direction from the current node, where 0 = left, 1 = right;
- // also the number of leaves of the left subtree
- uint32_t mask = 1 << (depth - 1);
+ // also the number of leaves of the left subtree. Unsigned: depth can reach 32.
+ uint32_t mask = 1U << (depth - 1);
uint8_t is_right_child = (index & mask) != 0 ? 1 : 0;
### src/common/merkle.h
@@ -62,7 +62,7 @@ void merkle_combine_hashes(const uint8_t left[static 32],
static inline uint8_t ceil_lg(uint32_t n) {
uint8_t r = 0;
uint32_t t = 1;
- while (t < n) {
+ while (t < n && r < 32) {
t = 2 * t;
++r;
}
### src/common/parser_ext.c
@@ -115,7 +115,9 @@ bool parser_consolidate_buffers(buffer_t *buffers[2], size_t max_size) {
}
memmove(buffers[0]->ptr, buffers[0]->ptr + buffers[0]->offset, length0);
- memmove(buffers[0]->ptr + length0, buffers[1]->ptr + buffers[1]->offset, length1);
+ if (length1 > 0) {
+ memmove(buffers[0]->ptr + length0, buffers[1]->ptr + buffers[1]->offset, length1);
+ }
buffers[0]->offset = 0;
buffers[0]->size = length0 + length1;
return true;
### src/handler/lib/check_merkle_tree_sorted.c
@@ -24,10 +24,12 @@ int call_check_merkle_tree_sorted_with_callback(dispatcher_context_t *dispatcher
for (size_t cur_el_idx = 0; cur_el_idx < size; cur_el_idx++) {
uint8_t cur_el[MAX_CHECK_MERKLE_TREE_SORTED_PREIMAGE_SIZE];
+ // tree size and leaf index are bounded well below 2^32 in practice;
+ // cast to the callee's uint32_t parameters explicitly.
int cur_el_len = call_get_merkle_leaf_element(dispatcher_context,
root,
- size,
- cur_el_idx,
+ (uint32_t) size,
+ (uint32_t) cur_el_idx,
cur_el,
sizeof(cur_el));
### src/handler/lib/policy.c
@@ -1964,7 +1964,7 @@ static int check_older_node_cb(const policy_node_t *node, void *callback_state)
(void) callback_state;
if (node->type == TOKEN_OLDER) {
const policy_node_with_uint32_t *older = (const policy_node_with_uint32_t *) node;
- uint32_t n = older->n & ~SEQUENCE_LOCKTIME_TYPE_FLAG;
+ uint32_t n = older->n & ~(uint32_t) SEQUENCE_LOCKTIME_TYPE_FLAG;
if (n < 1 || n > 65535) {
return -1;
}
### src/handler/sign_psbt.h
@@ -34,7 +34,7 @@ typedef struct {
// matched with the current key expression in the signing flow
bool is_change;
- int address_index;
+ uint32_t address_index;
// For an output, its scriptPubKey
// for an input, the prevout's scriptPubKey (either from the non-witness-utxo, or from the
### src/handler/sign_psbt/musig_signing.c
@@ -368,12 +368,14 @@ bool __attribute__((noinline)) sign_sighash_musig_and_yield(dispatcher_context_t
memcpy(musig_my_psbt_id + 33 + 33, keyexpr_info->tapleaf_hash, 32);
}
musig_pubnonce_t my_pubnonce;
- if (sizeof(musig_pubnonce_t) != call_get_merkleized_map_value(dc,
- &input->in_out.map,
- musig_my_psbt_id_key,
- 1 + psbt_id_len,
- my_pubnonce.raw,
- sizeof(musig_pubnonce_t))) {
+ // call_get_merkleized_map_value returns int (negative on error); cast the
+ // unsigned sizeof so the comparison doesn't trip UBSan's sign-change check.
+ if ((int) sizeof(musig_pubnonce_t) != call_get_merkleized_map_value(dc,
+ &input->in_out.map,
+ musig_my_psbt_id_key,
+ 1 + psbt_id_len,
+ my_pubnonce.raw,
+ sizeof(musig_pubnonce_t))) {
PRINTF("Missing or erroneous pubnonce in PSBT\n");
SEND_SW(dc, SW_INCORRECT_DATA);
return false;
### src/handler/sign_psbt/preprocess_inputs.c
@@ -186,6 +186,12 @@ bool __attribute__((noinline)) preprocess_inputs(
return false;
}
+ // sanity check before accumulating, to avoid overflowing the total
+ if (input.prevout_amount > BITCOIN_TOTAL_SUPPLY) {
+ PRINTF("Input amount exceeds Bitcoin total supply!\n");
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
st->inputs_total_amount += input.prevout_amount;
}
@@ -220,6 +226,12 @@ bool __attribute__((noinline)) preprocess_inputs(
}
} else {
// we extract the scriptPubKey and prevout amount from the witness utxo
+ // sanity check before accumulating, to avoid overflowing the total
+ if (wit_utxo_prevout_amount > BITCOIN_TOTAL_SUPPLY) {
+ PRINTF("Input amount exceeds Bitcoin total supply!\n");
+ SEND_SW(dc, SW_INCORRECT_DATA);
+ return false;
+ }
st->inputs_total_amount += wit_utxo_prevout_amount;
input.prevout_amount = wit_utxo_prevout_amount;
@@ -228,13 +240,6 @@ bool __attribute__((noinline)) preprocess_inputs(
}
}
- if (input.prevout_amount > BITCOIN_TOTAL_SUPPLY) {
- // sanity check to avoid overflows in amounts
- PRINTF("Input amount exceed Bitcoin total supply!\n");
- SEND_SW(dc, SW_INCORRECT_DATA);
- return false;
- }
-
// check if the input is internal; if not, continue
int is_internal = is_in_out_internal(dc, st, sign_psbt_cache, &input.in_out, true);Why this scored 63/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.