Count miniscript wrappers in the parser's recursion depth limit
What changed, and why it matters
This commit fixes a bug in the Ledger Bitcoin app's wallet-policy parser. Miniscript 'wrappers' (short letters like 'n' that modify a policy) were not counted toward the parser's recursion-depth safety limit. A crafted wallet descriptor with a long chain of wrappers could create an extremely deep policy tree, causing later recursive functions to exhaust the device's limited stack and crash. The patch now charges each wrapper against the same depth budget as nested expressions, and adds tests proving the boundary works.
Treat this as a security-hardening fix for a denial-of-service condition. Ensure the patch is included in the next release, run the new unit test, and consider whether any other AST walkers or parser paths have unbounded depth accounting gaps.
Security signals we found
Stack-exhaustion / denial-of-service via crafted descriptor template
Missing recursion-depth accounting for parser-level wrapper expansion
Patch adds explicit depth budget check and regression test
Affected function: compute_miniscript_policy_ext_info and other recursive AST walkers
No evidence of memory corruption or code execution in the diff
Evidence from the diff
parse_script in src/common/wallet.c enforces MAX_PARSE_SCRIPT_RECURSION_DEPTH while parsing descriptor templates, but previously only counted nested expressions, not miniscript wrappers. Wrappers are parsed in the same stack frame yet each creates a policy_node_with_script_t containing the next wrapper, so a chain such as ‘wsh(nnn…n:pk(@0/**))’ builds an arbitrarily deep AST. Recursive walkers such as compute_miniscript_policy_ext_info then overflow the stack. The fix adds a depth check before allocating wrapper nodes (depth + n_wrappers > MAX_PARSE_SCRIPT_RECURSION_DEPTH) and increments depth by n_wrappers before parsing the wrapped script. A regression test verifies that MAX_PARSE_SCRIPT_RECURSION_DEPTH wrappers is rejected and that the deepest accepted policy is processed safely.
Changed components
src/common/wallet.c: parse_scriptsrc/common/wallet.h: MAX_PARSE_SCRIPT_RECURSION_DEPTH definitionRecursive AST walkers using policy_node_t trees (e.g., compute_miniscript_policy_ext_info)Inspect captured patch +75 / −6
diff --git a/src/common/wallet.c b/src/common/wallet.c
index df5e467..c182b3a 100644
--- a/src/common/wallet.c
+++ b/src/common/wallet.c
@@ -23,12 +23,6 @@ typedef struct {
const char *name;
} token_descriptor_t;
-// As parse_script is recursive, we set a maximum reasonable recursion depth in order to avoid the
-// risk of stack exhaustion.
-// At the time of writing, the maximum depth measured across all the tests is 10, so 16 still
-// leaves a margin for much more complex scripts and seems unlikely to be hit in practice.
-#define MAX_PARSE_SCRIPT_RECURSION_DEPTH 16
-
static const token_descriptor_t KNOWN_TOKENS[] = {
{.type = TOKEN_SH, .name = "sh"},
{.type = TOKEN_WSH, .name = "wsh"},
@@ -688,6 +682,15 @@ static int parse_script(buffer_t *in_buf,
}
if (can_read && c == ':') {
+ // The wrappers are parsed in this same stack frame, but each of them creates a node
+ // containing the following one; therefore, they must be charged to the recursion
+ // budget explicitly. Otherwise, a short chain of wrappers that type-checks for any
+ // length (for example "nnn...n:pk(@0/**)") would produce an arbitrarily deep policy,
+ // exhausting the stack in the functions that walk it recursively.
+ if (depth + (size_t) n_wrappers > MAX_PARSE_SCRIPT_RECURSION_DEPTH) {
+ return WITH_ERROR(-1, "Script is too deeply nested");
+ }
+
// parse wrappers
for (int i = 0; i < n_wrappers; i++) {
policy_node_with_script_t *node =
@@ -740,6 +743,9 @@ static int parse_script(buffer_t *in_buf,
inner_wrapper = node;
}
buffer_seek_cur(in_buf, 1); // skip ":"
+
+ // the wrapped script is nested n_wrappers levels below the current one
+ depth += n_wrappers;
} else {
n_wrappers = 0; // it was not a wrapper
}
diff --git a/src/common/wallet.h b/src/common/wallet.h
index abfb4b6..7093bb2 100644
--- a/src/common/wallet.h
+++ b/src/common/wallet.h
@@ -57,6 +57,13 @@
#define MAX_DESCRIPTOR_TEMPLATE_LENGTH \
MAX(MAX_DESCRIPTOR_TEMPLATE_LENGTH_V1, MAX_DESCRIPTOR_TEMPLATE_LENGTH_V2)
+// As parse_script is recursive, we set a maximum reasonable recursion depth in order to avoid the
+// risk of stack exhaustion.
+// This depth is unlikely to be hit in practice.
+// Miniscript wrappers are counted as well: while they are not parsed recursively, they still
+// increase the depth of the parsed policy, which affects other recursive walkers.
+#define MAX_PARSE_SCRIPT_RECURSION_DEPTH 16
+
// 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 9628c98..80326e7 100644
--- a/unit-tests/test_wallet.c
+++ b/unit-tests/test_wallet.c
@@ -899,6 +899,61 @@ static void test_traverse_callback_abort(void **state) {
assert_int_equal(s.types[1], TOKEN_OR_I);
}
+/* ------------------------------------------------------------------------
+ * Maximum policy depth
+ *
+ * The parser bounds the depth of the parsed policy, as several functions walk
+ * it recursively. Miniscript wrappers create a node each, exactly like nested
+ * script expressions, and are therefore charged to the same budget: without
+ * that, a template as short as "wsh(nnn...n:pk(@0/**))" produces a policy
+ * hundreds of levels deep, and the recursive walkers (in particular
+ * compute_miniscript_policy_ext_info, used to check that a policy is sane) run
+ * out of stack while processing it.
+ * ------------------------------------------------------------------------ */
+
+// Builds "wsh(" + n_wrappers copies of "n" + ":pk(@0/**))" into out.
+// The wrapped script is at depth 1 + n_wrappers.
+static void make_wrapper_chain(char *out, size_t out_size, int n_wrappers) {
+ assert_true((size_t) n_wrappers + sizeof("wsh(:pk(@0/**))") <= out_size);
+ char *p = out + sprintf(out, "wsh(");
+ for (int i = 0; i < n_wrappers; i++) *p++ = 'n';
+ strcpy(p, ":pk(@0/**))");
+}
+
+static void test_parse_policy_max_depth_wrappers(void **state) {
+ (void) state;
+
+ // deep policies need more memory than the simple ones of the other tests
+ uint8_t out[4 * MAX_WALLET_POLICY_MEMORY_SIZE];
+ char policy[MAX_DESCRIPTOR_TEMPLATE_LENGTH + 1];
+
+ // the script inside wsh() is at depth 1, therefore one wrapper less than the limit fits
+ make_wrapper_chain(policy, sizeof(policy), MAX_PARSE_SCRIPT_RECURSION_DEPTH - 1);
+ assert_true(0 <= parse_policy(policy, out, sizeof(out)));
+
+ // ...and the chain is rejected as soon as it exceeds the budget, even by one
+ make_wrapper_chain(policy, sizeof(policy), MAX_PARSE_SCRIPT_RECURSION_DEPTH);
+ assert_true(0 > parse_policy(policy, out, sizeof(out)));
+
+ // a much longer chain still fits in a descriptor template; it must be rejected while parsing,
+ // before any recursive walk of the parsed policy
+ make_wrapper_chain(policy, sizeof(policy), 200);
+ assert_true(0 > parse_policy(policy, out, sizeof(out)));
+
+ // the deepest policy that is accepted must be processed correctly (and within the available
+ // stack) by the recursive functions that walk it afterwards
+ const int n_wrappers = MAX_PARSE_SCRIPT_RECURSION_DEPTH - 1;
+ make_wrapper_chain(policy, sizeof(policy), n_wrappers);
+ assert_true(0 <= parse_policy(policy, out, sizeof(out)));
+
+ 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);
+ // n:X adds a single OP_0NOTEQUAL on top of the 1 opcode of pk(key)
+ assert_int_equal(ext_info.ops.count, n_wrappers + 1);
+}
+
int main() {
const struct CMUnitTest tests[] = {
cmocka_unit_test(test_parse_policy_map_singlesig_1),
@@ -925,6 +980,7 @@ int main() {
cmocka_unit_test(test_traverse_tr_two_leaves),
cmocka_unit_test(test_traverse_tr_nested_tree),
cmocka_unit_test(test_traverse_callback_abort),
+ cmocka_unit_test(test_parse_policy_max_depth_wrappers),
};
return cmocka_run_group_tests(tests, NULL, NULL);
Why this scored 72/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.