What changed, and why it matters
This commit reduces a hard-coded limit in Ledger's Bitcoin app on how many branches a 'thresh' miniscript operator can have, from 128 down to 24. The change is framed as a memory-usage improvement, not a security fix. It also adds a test to make sure policies exceeding the new limit are rejected rather than analyzed with an undersized table. There is no direct evidence in the commit or supplied references that this was a disclosed vulnerability or that an exploit exists.
Treat as a defensive hardening change. Review whether the lowered limit is acceptable for all supported wallet policies and user-visible error messages. Consider adding an explicit runtime check on the thresh branch count before table allocation, so future increases to MAX_N_IN_THRESH do not reintroduce stack overflow risk. No urgent patch cycle is indicated absent evidence of active exploitation.
Security signals we found
Stack-memory pressure reduction in recursive miniscript analysis
Hard-coded threshold lowered to prevent large dynamic-programming tables
New unit test checks rejection of oversized thresh fragments
No explicit bounds check added independent of the constant
No vendor statement of security relevance or CVE in commit
Evidence from the diff
The constant MAX_N_IN_THRESH, governing the maximum number of branches (n) in a thresh() miniscript fragment, is lowered from 128 to 24 and moved from wallet.c to wallet.h. The functions compute_thresh_ops() and compute_thresh_stacksize() allocate dynamic-programming arrays sized by this constant; because compute_miniscript_policy_ext_info() is recursive and up to MAX_THRESH_NESTING (4) such arrays can be alive simultaneously, the old value caused substantial stack usage. The patch does not change the allocation logic or add runtime bounds checks beyond the existing constant; it relies on the lowered constant to keep stack consumption within acceptable limits. A unit test verifies that thresh(MAX_N_IN_THRESH) succeeds and thresh(MAX_N_IN_THRESH+1) returns an error from compute_miniscript_policy_ext_info().
Changed components
src/common/wallet.csrc/common/wallet.hunit-tests/test_wallet.ccompute_miniscript_policy_ext_info()compute_thresh_ops()compute_thresh_stacksize()Inspect captured patch +38 / −3
diff --git a/src/common/wallet.c b/src/common/wallet.c
index cea2fa3..52bf57e 100644
--- a/src/common/wallet.c
+++ b/src/common/wallet.c
@@ -2127,9 +2127,6 @@ static int16_t maxcheck(int16_t a, int16_t b) {
return a > b ? a : b;
}
-// Maximum supported value for n in a thresh miniscript operator (technical limitation)
-#define MAX_N_IN_THRESH 128
-
// The two functions below are kept out of line on purpose: their arrays would otherwise be part of
// the stack frame of compute_miniscript_policy_ext_info(), which is recursive, and would therefore
// be reserved once per level of the policy even for the nodes that are not thresh. As they are,
diff --git a/src/common/wallet.h b/src/common/wallet.h
index a6276a2..9c17fe9 100644
--- a/src/common/wallet.h
+++ b/src/common/wallet.h
@@ -67,6 +67,14 @@
// Maximum supported nesting of thresh operators
#define MAX_THRESH_NESTING 4
+// Maximum supported value for n in a thresh miniscript operator (technical limitation).
+// It also bounds the stack used while analyzing a policy: the arrays of compute_thresh_ops() and
+// compute_thresh_stacksize() are proportional to it, and up to MAX_THRESH_NESTING of them are
+// alive at the same time due to recursion, therefore this ends up eating a substantial amount of
+// memory.
+// This limit is extremely unlikely to be hit in practice.
+#define MAX_N_IN_THRESH 24
+
// at most 92 bytes
// wallet type (1 byte)
// name length (1 byte)
diff --git a/unit-tests/test_wallet.c b/unit-tests/test_wallet.c
index 1a5075e..f94c5b2 100644
--- a/unit-tests/test_wallet.c
+++ b/unit-tests/test_wallet.c
@@ -993,6 +993,35 @@ static void test_parse_policy_max_thresh_nesting(void **state) {
sizeof(out)));
}
+// Builds "wsh(thresh(1,pk(@0/**)" + n_branches - 1 copies of ",a:0" + "))".
+static void make_wide_thresh(char *out, size_t out_size, int n_branches) {
+ assert_true((size_t) n_branches * 4 + sizeof("wsh(thresh(1,pk(@0/**)))") <= out_size);
+ char *p = out + sprintf(out, "wsh(thresh(1,pk(@0/**)");
+ for (int i = 1; i < n_branches; i++) p += sprintf(p, ",a:0");
+ strcpy(p, "))");
+}
+
+// A thresh with more branches than the analysis supports must be reported as an error, rather
+// than being analyzed with a truncated (or overflowing) dynamic programming table.
+static void test_max_n_in_thresh(void **state) {
+ (void) state;
+
+ uint8_t out[4 * MAX_WALLET_POLICY_MEMORY_SIZE];
+ char policy[MAX_DESCRIPTOR_TEMPLATE_LENGTH + 1];
+ policy_node_ext_info_t ext_info;
+
+ make_wide_thresh(policy, sizeof(policy), MAX_N_IN_THRESH);
+ assert_true(0 <= parse_policy(policy, out, sizeof(out)));
+ const policy_node_t *inner = r_policy_node(&((policy_node_with_script_t *) out)->script);
+ assert_int_equal(compute_miniscript_policy_ext_info(inner, &ext_info, MINISCRIPT_CONTEXT_P2WSH),
+ 0);
+
+ make_wide_thresh(policy, sizeof(policy), MAX_N_IN_THRESH + 1);
+ assert_true(0 <= parse_policy(policy, out, sizeof(out)));
+ inner = r_policy_node(&((policy_node_with_script_t *) out)->script);
+ assert_true(0 > compute_miniscript_policy_ext_info(inner, &ext_info, MINISCRIPT_CONTEXT_P2WSH));
+}
+
int main() {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_parse_policy_map_singlesig_1),
@@ -1021,6 +1050,7 @@ int main() {
cmocka_unit_test(test_traverse_callback_abort),
cmocka_unit_test(test_parse_policy_max_depth_wrappers),
cmocka_unit_test(test_parse_policy_max_thresh_nesting),
+ cmocka_unit_test(test_max_n_in_thresh),
};
return cmocka_run_group_tests(tests, NULL, NULL);
Why this scored 43/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.