Skip to content

Commit 0a92dc6

Browse files
committed
fix(fuzz): apply follow-up review bundle
1 parent 6b23e82 commit 0a92dc6

13 files changed

Lines changed: 181 additions & 34 deletions

.github/workflows/test-fuzz.yml

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,29 +52,44 @@ jobs:
5252
run: |
5353
mkdir -p /tmp/fuzz_corpus
5454
55+
# Fail the job if neither external qa-assets source is reachable — otherwise we
56+
# silently degrade to synthetic-only/empty-corpus smoke runs and CI goes green on
57+
# no signal. Synthetic seeds are additive only; they do not substitute for the
58+
# curated external corpora.
59+
loaded_external=0
60+
5561
# Layer 1: bitcoin-core inherited corpus
5662
if git clone --depth=1 https://github.com/bitcoin-core/qa-assets /tmp/qa-assets; then
5763
if [ -d "/tmp/qa-assets/fuzz_seed_corpus" ]; then
5864
cp -r /tmp/qa-assets/fuzz_seed_corpus/. /tmp/fuzz_corpus/
5965
echo "Loaded bitcoin-core corpus"
66+
loaded_external=1
6067
fi
6168
else
62-
echo "WARNING: Failed to clone bitcoin-core/qa-assets (non-fatal)"
69+
echo "::warning::Failed to clone bitcoin-core/qa-assets"
6370
fi
6471
6572
# Layer 2: Dash-specific corpus (overlays on top)
6673
if git clone --depth=1 https://github.com/dashpay/qa-assets /tmp/dash-qa-assets; then
6774
if [ -d "/tmp/dash-qa-assets/fuzz/corpora" ]; then
6875
cp -r /tmp/dash-qa-assets/fuzz/corpora/. /tmp/fuzz_corpus/
6976
echo "Loaded Dash-specific corpus"
77+
loaded_external=1
7078
fi
7179
else
72-
echo "WARNING: Failed to clone dashpay/qa-assets (non-fatal)"
80+
echo "::warning::Failed to clone dashpay/qa-assets"
81+
fi
82+
83+
if [ "$loaded_external" -eq 0 ]; then
84+
echo "::error::No external corpus sources reachable - refusing to run synthetic-only/empty-corpus smoke tests that produce no signal"
85+
exit 1
7386
fi
7487
75-
# Layer 3: Generate synthetic seeds for Dash-specific targets
88+
# Layer 3: Generate synthetic seeds for Dash-specific targets (additive only —
89+
# gated on external corpus being present so we never pass on synthetic-only runs).
7690
if [ -f "contrib/fuzz/seed_corpus_from_chain.py" ]; then
77-
python3 contrib/fuzz/seed_corpus_from_chain.py --synthetic-only -o /tmp/fuzz_corpus
91+
python3 contrib/fuzz/seed_corpus_from_chain.py --synthetic-only -o /tmp/fuzz_corpus || \
92+
echo "::warning::Synthetic seed generation failed; continuing with external corpus only"
7893
fi
7994
shell: bash
8095

@@ -112,10 +127,15 @@ jobs:
112127
FAILED=0
113128
PASSED=0
114129
FAILED_TARGETS=""
130+
# libFuzzer writes crash-/leak-/oom-/timeout- files to this directory on failure
131+
# via -artifact_prefix, so the "Upload crash artifacts" step below can collect them.
132+
ARTIFACT_DIR=/tmp/fuzz_artifacts
133+
mkdir -p "$ARTIFACT_DIR"
115134
116135
while IFS= read -r target; do
117136
[ -z "$target" ] && continue
118137
corpus_dir="/tmp/fuzz_corpus/${target}"
138+
artifact_prefix="${ARTIFACT_DIR}/${target}-"
119139
120140
if [ ! -d "$corpus_dir" ] || [ -z "$(ls -A "$corpus_dir" 2>/dev/null)" ]; then
121141
# No corpus for this target — run with empty input for 10s
@@ -128,6 +148,7 @@ jobs:
128148
-rss_limit_mb=4000 \
129149
-max_total_time=10 \
130150
-reload=0 \
151+
-artifact_prefix="$artifact_prefix" \
131152
"$corpus_dir" 2>&1; then
132153
echo "PASS: $target (empty corpus)"
133154
PASSED=$((PASSED + 1))
@@ -146,6 +167,7 @@ jobs:
146167
if FUZZ="$target" timeout 3600 "$FUZZ_BIN" \
147168
-rss_limit_mb=4000 \
148169
-runs=0 \
170+
-artifact_prefix="$artifact_prefix" \
149171
"$corpus_dir" 2>&1; then
150172
echo "PASS: $target"
151173
PASSED=$((PASSED + 1))
@@ -171,3 +193,12 @@ jobs:
171193
exit 1
172194
fi
173195
shell: bash
196+
197+
- name: Upload crash artifacts
198+
if: failure() && steps.fuzz-test.conclusion == 'failure'
199+
uses: actions/upload-artifact@v4
200+
with:
201+
name: fuzz-crashes-${{ inputs.build-target }}
202+
path: /tmp/fuzz_artifacts
203+
if-no-files-found: ignore
204+
retention-days: 30

contrib/fuzz/seed_corpus_from_chain.py

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,24 +38,40 @@ def dash_cli(*args, datadir=None):
3838
return None
3939

4040

41-
# Must match src/version.h PROTOCOL_VERSION. The Dash-specific deserialize/
42-
# roundtrip fuzz harnesses (src/test/fuzz/deserialize_dash.cpp,
43-
# src/test/fuzz/roundtrip_dash.cpp) read a 4-byte little-endian int from the
44-
# start of the buffer and use it as the stream version before deserializing
45-
# the object. Chain data we extract is serialized at PROTOCOL_VERSION, so we
46-
# prepend that value to seeds for those targets.
47-
DASH_STREAM_VERSION = 70240
48-
DASH_STREAM_VERSION_PREFIX = DASH_STREAM_VERSION.to_bytes(4, byteorder="little", signed=False)
41+
# Must match src/version.h PROTOCOL_VERSION. Several fuzz harnesses read a
42+
# 4-byte little-endian int from the start of the buffer and use it as the
43+
# stream version before deserializing the object:
44+
# * The Dash-specific helpers DashDeserializeFromFuzzingInput /
45+
# DashRoundtripFromFuzzingInput (src/test/fuzz/deserialize_dash.cpp,
46+
# src/test/fuzz/roundtrip_dash.cpp), used by dash_*_deserialize and
47+
# dash_*_roundtrip targets.
48+
# * The upstream block target (src/test/fuzz/block.cpp), which reads the
49+
# version int inline before deserializing CBlock.
50+
# * The upstream block_deserialize target (src/test/fuzz/deserialize.cpp),
51+
# via DeserializeFromFuzzingInput when no explicit protocol_version is
52+
# passed.
53+
# Chain data we extract is serialized at PROTOCOL_VERSION, so we prepend
54+
# that value to seeds for those targets.
55+
STREAM_VERSION = 70240
56+
STREAM_VERSION_PREFIX = STREAM_VERSION.to_bytes(4, byteorder="little", signed=False)
57+
58+
# Non-Dash targets (outside the dash_* naming convention) whose harnesses
59+
# also consume the 4-byte stream version prefix described above.
60+
_NON_DASH_STREAM_VERSION_TARGETS = frozenset({"block", "block_deserialize"})
4961

5062

5163
def _needs_stream_version_prefix(target_name):
5264
"""Return True if this target's harness consumes a 4-byte stream version prefix.
5365
5466
Matches the DashDeserializeFromFuzzingInput / DashRoundtripFromFuzzingInput
55-
helpers used by every dash_*_deserialize and dash_*_roundtrip target. Non-Dash
56-
targets (block_deserialize, block, decode_tx, ...) don't use that helper and
57-
must be left untouched.
67+
helpers used by every dash_*_deserialize and dash_*_roundtrip target, plus
68+
the upstream ``block`` and ``block_deserialize`` targets which do the same
69+
(see src/test/fuzz/block.cpp and src/test/fuzz/deserialize.cpp). Other
70+
non-Dash targets (decode_tx, ...) don't consume such a prefix and must be
71+
left untouched.
5872
"""
73+
if target_name in _NON_DASH_STREAM_VERSION_TARGETS:
74+
return True
5975
return target_name.startswith("dash_") and (
6076
target_name.endswith("_deserialize") or target_name.endswith("_roundtrip")
6177
)
@@ -73,7 +89,7 @@ def save_corpus_input(output_dir, target_name, data_hex):
7389
return False
7490

7591
if _needs_stream_version_prefix(target_name):
76-
raw_bytes = DASH_STREAM_VERSION_PREFIX + raw_bytes
92+
raw_bytes = STREAM_VERSION_PREFIX + raw_bytes
7793

7894
filename = hashlib.sha256(raw_bytes).hexdigest()[:16]
7995
filepath = target_dir / filename
@@ -315,7 +331,42 @@ def extract_masternode_list(output_dir, datadir=None):
315331
if not protx_hash:
316332
continue
317333

318-
raw_tx = dash_cli("getrawtransaction", protx_hash, datadir=datadir)
334+
state = mn.get("state")
335+
if not isinstance(state, dict):
336+
print(f"WARNING: Missing state for protx {protx_hash}, skipping", file=sys.stderr)
337+
continue
338+
339+
registered_height = state.get("registeredHeight")
340+
try:
341+
registered_height = int(registered_height)
342+
except (TypeError, ValueError):
343+
print(
344+
f"WARNING: Invalid registeredHeight for protx {protx_hash}: {registered_height!r}",
345+
file=sys.stderr,
346+
)
347+
continue
348+
349+
block_hash = dash_cli("getblockhash", str(registered_height), datadir=datadir)
350+
if not block_hash:
351+
continue
352+
353+
block_json = dash_cli("getblock", block_hash, "1", datadir=datadir)
354+
if not block_json:
355+
continue
356+
357+
try:
358+
block = json.loads(block_json)
359+
except json.JSONDecodeError:
360+
continue
361+
362+
if protx_hash not in block.get("tx", []):
363+
print(
364+
f"WARNING: ProRegTx {protx_hash} not found in registeredHeight block {registered_height} ({block_hash}), skipping",
365+
file=sys.stderr,
366+
)
367+
continue
368+
369+
raw_tx = dash_cli("getrawtransaction", protx_hash, "false", block_hash, datadir=datadir)
319370
if not raw_tx:
320371
continue
321372

@@ -325,7 +376,7 @@ def extract_masternode_list(output_dir, datadir=None):
325376

326377
# Extract the special payload for payload-specific targets
327378
# ProRegTx type is 1, get extraPayloadSize from verbose tx
328-
verbose_tx = dash_cli("getrawtransaction", protx_hash, "true", datadir=datadir)
379+
verbose_tx = dash_cli("getrawtransaction", protx_hash, "true", block_hash, datadir=datadir)
329380
if not verbose_tx:
330381
continue
331382
try:

src/coinjoin/coinjoin.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ bool CCoinJoinQueue::CheckSignature(const CBLSPublicKey& blsPubKey) const
5757

5858
bool CCoinJoinQueue::IsTimeOutOfBounds(int64_t current_time) const
5959
{
60+
// Both operands are gated to be non-negative, so the differences cannot overflow int64_t.
6061
if (current_time < 0 || nTime < 0) return true;
61-
const uint64_t diff = (current_time > nTime) ? static_cast<uint64_t>(current_time) - static_cast<uint64_t>(nTime)
62-
: static_cast<uint64_t>(nTime) - static_cast<uint64_t>(current_time);
63-
return diff > static_cast<uint64_t>(COINJOIN_QUEUE_TIMEOUT);
62+
return current_time - nTime > COINJOIN_QUEUE_TIMEOUT ||
63+
nTime - current_time > COINJOIN_QUEUE_TIMEOUT;
6464
}
6565

6666
[[nodiscard]] std::string CCoinJoinQueue::ToString() const

src/coinjoin/common.h

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@
1010
#include <consensus/amount.h>
1111
#include <primitives/transaction.h>
1212

13-
#include <algorithm>
1413
#include <array>
15-
#include <limits>
1614
#include <string>
1715

1816
/** Holds a mixing input
@@ -130,8 +128,9 @@ constexpr int CalculateAmountPriority(CAmount nInputAmount)
130128
}
131129

132130
//nondenom return largest first
133-
const int64_t val = -(nInputAmount / COIN);
134-
return int(std::clamp<int64_t>(val, std::numeric_limits<int>::min(), std::numeric_limits<int>::max()));
131+
// nInputAmount is bounded to [0, MAX_MONEY] by the guard at the top of this function,
132+
// so (nInputAmount / COIN) fits comfortably in int.
133+
return -1 * (nInputAmount / COIN);
135134
}
136135

137136
} // namespace CoinJoin

src/llmq/snapshot.h

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,12 @@ class CQuorumSnapshot
7878
size_t cnt = ReadCompactSize(s);
7979
ReadFixedBitSet(s, activeQuorumMembers, cnt);
8080
cnt = ReadCompactSize(s);
81+
// Reset the destination so in-place re-deserialization (e.g. via CDBWrapper::Read
82+
// reusing an existing snapshot object) doesn't append onto stale entries.
83+
// Note: intentionally no reserve(cnt) — cnt comes from an unbounded ReadCompactSize
84+
// on a P2P-reachable path (QRINFO), and eager reservation would let a peer force
85+
// large allocations from a tiny message.
8186
mnSkipList.clear();
82-
mnSkipList.reserve(cnt);
8387
for ([[maybe_unused]] const auto _ : util::irange(cnt)) {
8488
int obj;
8589
s >> obj;

src/test/fuzz/decode_tx.cpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414

1515
FUZZ_TARGET(decode_tx)
1616
{
17+
// Dash's DecodeHexTx takes no try_no_witness/try_witness flags (no witness support),
18+
// so unlike upstream we can't assert all decode attempts fail — a fuzzer-generated
19+
// buffer can coincidentally be a valid serialized transaction.
1720
const std::string tx_hex = HexStr(std::string{buffer.begin(), buffer.end()});
1821
CMutableTransaction mtx;
1922
(void)DecodeHexTx(mtx, tx_hex);

src/test/fuzz/governance_proposal_validator.cpp

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ std::string HexEncodeString(const std::string& input)
2121
static constexpr char HEX_DIGITS[] = "0123456789abcdef";
2222
std::string out;
2323
out.reserve(input.size() * 2);
24-
for (const unsigned char ch : input) {
24+
// std::string holds char, which may be signed. Cast explicitly to avoid UBSan's
25+
// implicit-integer-sign-change on the char->unsigned char conversion.
26+
for (const char raw : input) {
27+
const auto ch = static_cast<unsigned char>(raw);
2528
out.push_back(HEX_DIGITS[ch >> 4]);
2629
out.push_back(HEX_DIGITS[ch & 0x0f]);
2730
}

src/test/fuzz/process_message.cpp

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717
#include <validationinterface.h>
1818
#include <version.h>
1919

20+
#include <algorithm>
21+
#include <array>
2022
#include <cstdlib>
2123
#include <iostream>
2224
#include <memory>
23-
#include <set>
2425
#include <string>
2526
#include <string_view>
2627
#include <vector>
@@ -62,9 +63,11 @@ FUZZ_TARGET(process_message, .init = initialize_process_message)
6263
if (!LIMIT_TO_MESSAGE_TYPE.empty() && random_message_type != LIMIT_TO_MESSAGE_TYPE) {
6364
return;
6465
}
65-
// Skip Dash message types that require subsystem initialization not present in the fuzz harness
66-
static const std::set<std::string> skip_message_types{"mnauth"};
67-
if (skip_message_types.count(random_message_type)) {
66+
// Skip Dash message types that require subsystem initialization not present in the fuzz harness.
67+
// TODO: initialize the masternode/LLMQ contexts here (see process_message_dash.cpp) and remove
68+
// this list so these handlers get real coverage.
69+
static constexpr std::array<std::string_view, 1> skip_message_types{"mnauth"};
70+
if (std::find(skip_message_types.begin(), skip_message_types.end(), random_message_type) != skip_message_types.end()) {
6871
return;
6972
}
7073
CNode& p2p_node = *ConsumeNodeAsUniquePtr(fuzzed_data_provider).release();

src/test/fuzz/rpc.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,17 @@ const std::vector<std::string> RPC_COMMANDS_NOT_SAFE_FOR_FUZZING{
9797
"getaddressmempool", // Dash: requires address index
9898
"getaddresstxids", // Dash: requires address index
9999
"getaddressutxos", // Dash: requires address index
100+
"getassetunlockstatuses", // Dash: requires chainstate
100101
"getbestchainlock", // Dash: requires LLMQ subsystem
102+
"getblockfrompeer", // avoid network activity
101103
"getblockhashes", // Dash: requires timestamp index
102104
"getblockheaders", // Dash: requires block index
103105
"getcoinjoininfo", // Dash: requires MN subsystem
104106
"getgovernanceinfo", // Dash: requires governance subsystem
105107
"getislocks", // Dash: requires LLMQ subsystem
106108
"getmerkleblocks", // Dash: requires block index
109+
"getrawtransactionmulti", // Dash: requires chainstate
110+
"gettxchainlocks", // Dash: requires LLMQ subsystem
107111
"getspentinfo", // Dash: requires spent index
108112
"getspecialtxes", // Dash: requires block index
109113
"getsuperblockbudget", // Dash: requires governance subsystem
@@ -112,8 +116,10 @@ const std::vector<std::string> RPC_COMMANDS_NOT_SAFE_FOR_FUZZING{
112116
"gobject check", // Dash: requires governance subsystem
113117
"gobject count", // Dash: requires governance subsystem
114118
"gobject deserialize", // Dash: requires governance subsystem
119+
"gobject diff", // Dash: requires governance subsystem
115120
"gobject get", // Dash: requires governance subsystem
116121
"gobject getcurrentvotes", // Dash: requires governance subsystem
122+
"gobject list", // Dash: requires governance subsystem
117123
"gobject list-prepared", // Dash: requires governance subsystem
118124
"gobject prepare", // Dash: requires governance subsystem
119125
"gobject submit", // Dash: requires governance subsystem
@@ -124,20 +130,24 @@ const std::vector<std::string> RPC_COMMANDS_NOT_SAFE_FOR_FUZZING{
124130
"masternode", // Dash: requires MN subsystem
125131
"masternode connect", // Dash: requires MN subsystem
126132
"masternode count", // Dash: requires MN subsystem
133+
"masternode list", // Dash: requires MN subsystem
127134
"masternode outputs", // Dash: requires MN subsystem
128135
"masternode payments", // Dash: requires MN subsystem
129136
"masternode status", // Dash: requires MN subsystem
130137
"masternode winners", // Dash: requires MN subsystem
131138
"masternodelist", // Dash: requires MN subsystem
132139
"mnauth", // Dash: requires MN subsystem
133140
"mnsync", // Dash: requires MN subsystem
141+
"protx", // Dash: requires MN subsystem
134142
"protx diff", // Dash: requires MN subsystem
135143
"protx info", // Dash: requires MN subsystem
136144
"protx list", // Dash: requires MN subsystem
137145
"protx listdiff", // Dash: requires MN subsystem
138146
"protx register_submit", // Dash: requires MN subsystem
139147
"protx revoke", // Dash: requires MN subsystem
140148
"protx update_service", // Dash: requires MN subsystem
149+
"quorum", // Dash: requires LLMQ subsystem
150+
"quorum dkginfo", // Dash: requires LLMQ subsystem
141151
"quorum dkgsimerror", // Dash: requires LLMQ subsystem
142152
"quorum dkgstatus", // Dash: requires LLMQ subsystem
143153
"quorum getdata", // Dash: requires LLMQ subsystem
@@ -149,6 +159,7 @@ const std::vector<std::string> RPC_COMMANDS_NOT_SAFE_FOR_FUZZING{
149159
"quorum listextended", // Dash: requires LLMQ subsystem
150160
"quorum memberof", // Dash: requires LLMQ subsystem
151161
"quorum platformsign", // Dash: requires LLMQ subsystem
162+
"quorum rotationinfo", // Dash: requires LLMQ subsystem
152163
"quorum selectquorum", // Dash: requires LLMQ subsystem
153164
"quorum sign", // Dash: requires LLMQ subsystem
154165
"quorum verify", // Dash: requires LLMQ subsystem

src/test/fuzz/simplified_mn_list_diff.cpp

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,18 @@ FUZZ_TARGET(simplified_mn_list_diff, .init = initialize_simplified_mn_list_diff)
126126
new_state->BanIfNotBanned(fuzzed_data_provider.ConsumeIntegralInRange<int>(0, 100000));
127127
break;
128128
case 7:
129-
new_state->platformNodeID = Uint160FromTag(next_unique_tag++ ^ 0x12121212ULL);
129+
// platformNodeID is only serialized in CSimplifiedMNListEntry when nType == Evo.
130+
// Mutating it on a Regular MN would break the round-trip invariant below.
131+
if (old_mn->nType == MnType::Evo) {
132+
new_state->platformNodeID = Uint160FromTag(next_unique_tag++ ^ 0x12121212ULL);
133+
}
130134
break;
131135
case 8:
132-
new_state->platformHTTPPort = fuzzed_data_provider.ConsumeIntegral<uint16_t>();
136+
// platformHTTPPort is only serialized when nType == Evo && nVersion < ExtAddr;
137+
// MakeMasternode/case 1 never set nVersion >= ExtAddr, so guarding on Evo suffices.
138+
if (old_mn->nType == MnType::Evo) {
139+
new_state->platformHTTPPort = fuzzed_data_provider.ConsumeIntegral<uint16_t>();
140+
}
133141
break;
134142
case 9:
135143
new_state->nRegisteredHeight = fuzzed_data_provider.ConsumeIntegralInRange<int>(0, 10000);

0 commit comments

Comments
 (0)