Skip to content

Commit 262260c

Browse files
committed
Merge bitcoin/bitcoin#30197: fuzz: bound some miniscript operations to avoid fuzz timeouts
bc34bc2 fuzz: limit the number of nested wrappers in descriptors (Antoine Poinsot) 8d73401 fuzz: limit the number of sub-fragments per fragment for descriptors (Antoine Poinsot) Pull request description: Some of the logic in the miniscript module is quadratic. It only becomes an issue for very large uninteresting descriptors (like a `thresh` with 130k sub-fragments or a fragment with more than 60k nested `j:` wrappers). This PR fixes the two types of fuzz timeouts reported by Marco in bitcoin/bitcoin#28812 by trying to pinpoint the problematic descriptors through a simple analysis of the string, without limiting the size of the string itself. This is the same approach as was adopted for limiting the depth of derivation paths. ACKs for top commit: dergoegge: utACK bc34bc2 stickies-v: Light ACK bc34bc2 marcofleon: Code review ACK bc34bc2. The added comments are useful, thanks for those. Tested on the three inputs in bitcoin/bitcoin#28812 that caused the timeouts. Tree-SHA512: 8811c7b225684c5ecc1eb1256cf39dfa60d4518161e70210086c8a01b38927481ebe747af86aa5f4803187672d43fadabcfdfbf4e3b049738d629a25143f0e77
2 parents 84063a4 + bc34bc2 commit 262260c

File tree

3 files changed

+91
-1
lines changed

3 files changed

+91
-1
lines changed

src/test/fuzz/descriptor_parse.cpp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ FUZZ_TARGET(mocked_descriptor_parse, .init = initialize_mocked_descriptor_parse)
7272
// out strings which could correspond to a descriptor containing a too large derivation path.
7373
if (HasDeepDerivPath(buffer)) return;
7474

75+
// Some fragments can take a virtually unlimited number of sub-fragments (thresh, multi_a) but
76+
// may perform quadratic operations on them. Limit the number of sub-fragments per fragment.
77+
if (HasTooManySubFrag(buffer)) return;
78+
79+
// The script building logic performs quadratic copies in the number of nested wrappers. Limit
80+
// the number of nested wrappers per fragment.
81+
if (HasTooManyWrappers(buffer)) return;
82+
7583
const std::string mocked_descriptor{buffer.begin(), buffer.end()};
7684
if (const auto descriptor = MOCKED_DESC_CONVERTER.GetDescriptor(mocked_descriptor)) {
7785
FlatSigningProvider signing_provider;
@@ -83,8 +91,10 @@ FUZZ_TARGET(mocked_descriptor_parse, .init = initialize_mocked_descriptor_parse)
8391

8492
FUZZ_TARGET(descriptor_parse, .init = initialize_descriptor_parse)
8593
{
86-
// See comment above for rationale.
94+
// See comments above for rationales.
8795
if (HasDeepDerivPath(buffer)) return;
96+
if (HasTooManySubFrag(buffer)) return;
97+
if (HasTooManyWrappers(buffer)) return;
8898

8999
const std::string descriptor(buffer.begin(), buffer.end());
90100
FlatSigningProvider signing_provider;

src/test/fuzz/util/descriptor.cpp

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
#include <test/fuzz/util/descriptor.h>
66

7+
#include <ranges>
8+
#include <stack>
9+
710
void MockedDescriptorConverter::Init() {
811
// The data to use as a private key or a seed for an xprv.
912
std::array<std::byte, 32> key_data{std::byte{1}};
@@ -84,3 +87,59 @@ bool HasDeepDerivPath(const FuzzBufferType& buff, const int max_depth)
8487
}
8588
return false;
8689
}
90+
91+
bool HasTooManySubFrag(const FuzzBufferType& buff, const int max_subs, const size_t max_nested_subs)
92+
{
93+
// We use a stack because there may be many nested sub-frags.
94+
std::stack<int> counts;
95+
for (const auto& ch: buff) {
96+
// The fuzzer may generate an input with a ton of parentheses. Rule out pathological cases.
97+
if (counts.size() > max_nested_subs) return true;
98+
99+
if (ch == '(') {
100+
// A new fragment was opened, create a new sub-count for it and start as one since any fragment with
101+
// parentheses has at least one sub.
102+
counts.push(1);
103+
} else if (ch == ',' && !counts.empty()) {
104+
// When encountering a comma, account for an additional sub in the last opened fragment. If it exceeds the
105+
// limit, bail.
106+
if (++counts.top() > max_subs) return true;
107+
} else if (ch == ')' && !counts.empty()) {
108+
// Fragment closed! Drop its sub count and resume to counting the number of subs for its parent.
109+
counts.pop();
110+
}
111+
}
112+
return false;
113+
}
114+
115+
bool HasTooManyWrappers(const FuzzBufferType& buff, const int max_wrappers)
116+
{
117+
// The number of nested wrappers. Nested wrappers are always characters which follow each other so we don't have to
118+
// use a stack as we do above when counting the number of sub-fragments.
119+
std::optional<int> count;
120+
121+
// We want to detect nested wrappers. A wrapper is a character prepended to a fragment, separated by a colon. There
122+
// may be more than one wrapper, in which case the colon is not repeated. For instance `jjjjj:pk()`. To count
123+
// wrappers we iterate in reverse and use the colon to detect the end of a wrapper expression and count how many
124+
// characters there are since the beginning of the expression. We stop counting when we encounter a character
125+
// indicating the beginning of a new expression.
126+
for (const auto ch: buff | std::views::reverse) {
127+
// A colon, start counting.
128+
if (ch == ':') {
129+
// The colon itself is not a wrapper so we start at 0.
130+
count = 0;
131+
} else if (count) {
132+
// If we are counting wrappers, stop when we crossed the beginning of the wrapper expression. Otherwise keep
133+
// counting and bail if we reached the limit.
134+
// A wrapper may only ever occur as the first sub of a descriptor/miniscript expression ('('), as the
135+
// first Taproot leaf in a pair ('{') or as the nth sub in each case (',').
136+
if (ch == ',' || ch == '(' || ch == '{') {
137+
count.reset();
138+
} else if (++*count > max_wrappers) {
139+
return true;
140+
}
141+
}
142+
}
143+
144+
return false;
145+
}

src/test/fuzz/util/descriptor.h

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,25 @@ constexpr int MAX_DEPTH{2};
5555
*/
5656
bool HasDeepDerivPath(const FuzzBufferType& buff, const int max_depth = MAX_DEPTH);
5757

58+
//! Default maximum number of sub-fragments.
59+
constexpr int MAX_SUBS{1'000};
60+
//! Maximum number of nested sub-fragments we'll allow in a descriptor.
61+
constexpr size_t MAX_NESTED_SUBS{10'000};
62+
63+
/**
64+
* Whether the buffer, if it represents a valid descriptor, contains a fragment with more
65+
* sub-fragments than the given maximum.
66+
*/
67+
bool HasTooManySubFrag(const FuzzBufferType& buff, const int max_subs = MAX_SUBS,
68+
const size_t max_nested_subs = MAX_NESTED_SUBS);
69+
70+
//! Default maximum number of wrappers per fragment.
71+
constexpr int MAX_WRAPPERS{100};
72+
73+
/**
74+
* Whether the buffer, if it represents a valid descriptor, contains a fragment with more
75+
* wrappers than the given maximum.
76+
*/
77+
bool HasTooManyWrappers(const FuzzBufferType& buff, const int max_wrappers = MAX_WRAPPERS);
78+
5879
#endif // BITCOIN_TEST_FUZZ_UTIL_DESCRIPTOR_H

0 commit comments

Comments
 (0)