Limit the amount of nesting for `thresh` fragments to 4
What changed, and why it matters
This commit fixes a stack-overflow risk in Ledger's Bitcoin app when parsing wallet policies that deeply nest 'thresh' miniscript fragments. It limits nesting to four levels and prevents two helper functions from being inlined so their large local arrays don't multiply across every recursive call. Without the fix, a crafted policy could exhaust the device's limited stack and crash or potentially corrupt memory.
Treat this as a security hardening fix and include it in the next firmware release. Review whether other recursive walkers in the codebase have similar inlined large-array issues, and consider adding runtime stack-canary or static-analysis checks for large stack frames in security-critical parsing code.
Security signals we found
Stack-frame bloat from inlined functions with large local arrays
Recursive policy-tree walker with unbounded nested thresh input
Potential stack exhaustion / overflow on crafted miniscript policy
New parse-time limit rejects deeply nested thresh fragments
Helper functions made noinline to scope large arrays to their own frames
Evidence from the diff
The patch addresses excessive stack usage in compute_miniscript_policy_ext_info(). compute_thresh_ops and compute_thresh_stacksize each declare two uint16_t arrays of size MAX_N_IN_THRESH+2 (about 520 bytes each, ~600 bytes total per function). When inlined, those arrays become part of the caller’s stack frame, which is recursive over the whole policy tree, so the bloat is incurred at every recursion level. The patch marks both functions with attribute((noinline)) so their stack is only used while actually processing a thresh node, and adds a parse-time limit of MAX_THRESH_NESTING (4) for nested thresh expressions by encoding a counter in the upper bits of context_flags. Unit tests verify that 4 levels are accepted, 5 are rejected, and sibling thresh nodes are unaffected.
Changed components
src/common/wallet.c: parse_script() thresh handlingsrc/common/wallet.c: compute_thresh_ops()src/common/wallet.c: compute_thresh_stacksize()src/common/wallet.h: MAX_THRESH_NESTING constantunit-tests/test_wallet.c: new test_parse_policy_max_thresh_nesting testInspect captured patch +68 / −7
diff --git a/src/common/wallet.c b/src/common/wallet.c
index c182b3a..cea2fa3 100644
--- a/src/common/wallet.c
+++ b/src/common/wallet.c
@@ -598,6 +598,13 @@ static int parse_keyexpr(buffer_t *in_buf,
#define CONTEXT_WITHIN_WSH 2 // parsing a direct child of WSH
#define CONTEXT_WITHIN_TR 4 // parsing a child of TR (direct or not)
+// The remaining bits of the context flags count the THRESH nodes that contain the script being
+// parsed. Each of them costs about 600 bytes of stack while the extended info of the policy is
+// computed (see compute_thresh_ops), therefore their nesting is limited by MAX_THRESH_NESTING;
+// policies with thresh expressions with more nesting seem unlikely to be used in practice.
+#define CONTEXT_THRESH_NESTING_UNIT 8
+#define CONTEXT_THRESH_NESTING(flags) ((flags) / CONTEXT_THRESH_NESTING_UNIT)
+
// forward declaration
static int parse_script(buffer_t *in_buf,
buffer_t *out_buf,
@@ -1376,6 +1383,12 @@ static int parse_script(buffer_t *in_buf,
break;
}
case TOKEN_THRESH: {
+ if (CONTEXT_THRESH_NESTING(context_flags) >= MAX_THRESH_NESTING) {
+ return WITH_ERROR(-1, "Too many nested thresh expressions");
+ }
+ // the children of this node (and all their descendants) are within one more thresh
+ unsigned int inner_context_flags = context_flags + CONTEXT_THRESH_NESTING_UNIT;
+
policy_node_thresh_t *node =
(policy_node_thresh_t *) buffer_alloc(out_buf, sizeof(policy_node_thresh_t), true);
if (node == NULL) {
@@ -1421,7 +1434,7 @@ static int parse_script(buffer_t *in_buf,
// parse a script into cur->script
buffer_alloc(out_buf, 0, true); // ensure alignment of current pointer
i_policy_node(&cur->script, buffer_get_cur(out_buf));
- if (0 > parse_script(in_buf, out_buf, version, depth + 1, context_flags)) {
+ if (0 > parse_script(in_buf, out_buf, version, depth + 1, inner_context_flags)) {
// failed while parsing internal script
return -1;
}
@@ -2117,9 +2130,14 @@ static int16_t maxcheck(int16_t a, int16_t b) {
// Maximum supported value for n in a thresh miniscript operator (technical limitation)
#define MAX_N_IN_THRESH 128
-static int compute_thresh_ops(const policy_node_thresh_t *node,
- miniscript_ops_t *out,
- MiniscriptContext ctx) {
+// 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,
+// they only use stack while a thresh node is being processed, and the nesting of thresh nodes is
+// limited to MAX_THRESH_NESTING while parsing.
+__attribute__((noinline)) static int compute_thresh_ops(const policy_node_thresh_t *node,
+ miniscript_ops_t *out,
+ MiniscriptContext ctx) {
uint16_t sats[MAX_N_IN_THRESH + 1 + 1] = {0};
uint16_t next_sats[MAX_N_IN_THRESH + 1 + 1] = {0}; // it temporarily uses an extra element
@@ -2156,9 +2174,9 @@ static int compute_thresh_ops(const policy_node_thresh_t *node,
return 0;
}
-static int compute_thresh_stacksize(const policy_node_thresh_t *node,
- miniscript_stacksize_t *out,
- MiniscriptContext ctx) {
+__attribute__((noinline)) static int compute_thresh_stacksize(const policy_node_thresh_t *node,
+ miniscript_stacksize_t *out,
+ MiniscriptContext ctx) {
uint16_t sats[MAX_N_IN_THRESH + 1 + 1] = {0};
uint16_t next_sats[MAX_N_IN_THRESH + 1 + 1] = {0}; // it temporarily uses an extra element
diff --git a/src/common/wallet.h b/src/common/wallet.h
index 7093bb2..a6276a2 100644
--- a/src/common/wallet.h
+++ b/src/common/wallet.h
@@ -64,6 +64,9 @@
// increase the depth of the parsed policy, which affects other recursive walkers.
#define MAX_PARSE_SCRIPT_RECURSION_DEPTH 16
+// Maximum supported nesting of thresh operators
+#define MAX_THRESH_NESTING 4
+
// 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 80326e7..1a5075e 100644
--- a/unit-tests/test_wallet.c
+++ b/unit-tests/test_wallet.c
@@ -954,6 +954,45 @@ static void test_parse_policy_max_depth_wrappers(void **state) {
assert_int_equal(ext_info.ops.count, n_wrappers + 1);
}
+// Builds "wsh(" + n_levels copies of "thresh(1," + "pk(@0/**)" + the closing parentheses.
+static void make_thresh_chain(char *out, size_t out_size, int n_levels) {
+ assert_true((size_t) n_levels * 10 + sizeof("wsh(pk(@0/**))") <= out_size);
+ char *p = out + sprintf(out, "wsh(");
+ for (int i = 0; i < n_levels; i++) p += sprintf(p, "thresh(1,");
+ p += sprintf(p, "pk(@0/**)");
+ for (int i = 0; i < n_levels; i++) *p++ = ')';
+ strcpy(p, ")");
+}
+
+static void test_parse_policy_max_thresh_nesting(void **state) {
+ (void) state;
+
+ uint8_t out[4 * MAX_WALLET_POLICY_MEMORY_SIZE];
+ char policy[MAX_DESCRIPTOR_TEMPLATE_LENGTH + 1];
+
+ make_thresh_chain(policy, sizeof(policy), MAX_THRESH_NESTING);
+ assert_true(0 <= parse_policy(policy, out, sizeof(out)));
+
+ // the deepest accepted nesting must also be processed by the recursive walkers
+ const policy_node_t *inner = r_policy_node(&((policy_node_with_script_t *) out)->script);
+ policy_node_ext_info_t ext_info;
+ assert_int_equal(compute_miniscript_policy_ext_info(inner, &ext_info, MINISCRIPT_CONTEXT_P2WSH),
+ 0);
+
+ make_thresh_chain(policy, sizeof(policy), MAX_THRESH_NESTING + 1);
+ assert_true(0 > parse_policy(policy, out, sizeof(out)));
+
+ // a chain of nested thresh still fits in a descriptor template well beyond the depth limit
+ make_thresh_chain(policy, sizeof(policy), MAX_PARSE_SCRIPT_RECURSION_DEPTH + 1);
+ assert_true(0 > parse_policy(policy, out, sizeof(out)));
+
+ // the limit is on nested thresh only: many thresh nodes as siblings are still accepted
+ assert_true(0 <=
+ parse_policy("wsh(thresh(1,thresh(1,pk(@0/**)),sc:pk_k(@1/**),sc:pk_k(@2/**)))",
+ out,
+ sizeof(out)));
+}
+
int main() {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_parse_policy_map_singlesig_1),
@@ -981,6 +1020,7 @@ int main() {
cmocka_unit_test(test_traverse_tr_nested_tree),
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),
};
return cmocka_run_group_tests(tests, NULL, NULL);
Why this scored 59/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.