fuzzing: gate fuzz-only relaxations behind FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
What changed, and why it matters
This commit only changes how the app's internal fuzz-testing harness behaves. It does not alter normal production behavior. The changes add compile-time guards so that certain safety relaxations (like skipping duplicate-public-key checks or forcing swap checks to run) are active only when the code is built in a special fuzzing mode. One small non-fuzzing change moves a Bech32 character table from a pointer into read-only data, which is a hardening improvement but not a fix for an exploitable vulnerability.
No urgent action. Review that CI builds for production firmware do not define FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION. Consider whether the .rodata charset change warrants a minor hardening note in release notes.
Security signals we found
Fuzz-only relaxations now guarded by FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
No production code path weakened; production paths retain original checks
Bech32 charset moved from pointer to array to place it in .rodata
Commit title and message explicitly describe fuzzing-only gating
Evidence from the diff
The commit gates four previously unconditional fuzzing relaxations behind FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION: (1) bounding Merkle tree sizes to avoid fuzzer blow-ups, (2) allowing duplicate pubkeys because the crypto mock returns a constant key, (3) forcing swap default-wallet and warning checks to pass, and (4) relaxing the cross-chain-swap external-output count. These relaxations are now explicitly excluded from production builds. Additionally, the Bech32 charset is changed from a const char * to a const char[] so it resides in .rodata rather than the Absolution prefix region, a hardening/cleanup change.
Changed components
src/common/merkle.csrc/common/segwit_addr.csrc/handler/lib/policy.csrc/handler/sign_psbt/swap_checks.cInspect captured patch +39 / −2
### src/common/merkle.c
@@ -55,7 +55,12 @@ int merkle_get_ith_direction(size_t size, size_t index, size_t i) {
if (size <= 1 || index >= size) {
return -1;
}
-
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Bound fuzz-only sizes to avoid catastrophic iteration counts.
+ if (size > ((size_t) 1U << MAX_MERKLE_TREE_DEPTH)) {
+ return -1;
+ }
+#endif
uint8_t n_directions = 0;
while (size > 1) {
uint8_t depth = ceil_lg(size);
### src/common/segwit_addr.c
@@ -44,7 +44,9 @@ static uint32_t bech32_final_constant(bech32_encoding enc) {
return 0; // suppress compiler warning on missing return value
}
-static const char* charset = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
+// `const` array (not a string-literal pointer) so the alphabet lands in
+// .rodata, keeping it out of the Absolution prefix.
+static const char charset[] = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
static const int8_t charset_rev[128] = {
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
### src/handler/lib/policy.c
@@ -2023,8 +2023,13 @@ int is_policy_sane(dispatcher_context_t *dispatcher_context,
if (memcmp(pubkey_i.compressed_pubkey,
pubkey_j.compressed_pubkey,
sizeof(pubkey_i.compressed_pubkey)) == 0) {
+#ifndef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
// duplicated pubkey
return WITH_ERROR(-1, "Repeated pubkey in wallet policy");
+#else
+ // Fuzz mode: the crypto mock returns a constant pubkey for every
+ // derivation, so allow the collision to keep multi-key policies reachable.
+#endif
}
}
}
### src/handler/sign_psbt/swap_checks.c
@@ -41,9 +41,14 @@ bool __attribute__((noinline)) execute_swap_checks(dispatcher_context_t *dc,
// Swap feature: check that wallet policy is a default one
if (!st->account.is_default) {
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Fuzzing repair: force the default-wallet branch so swap validation runs.
+ st->account.is_default = true;
+#else
PRINTF("Must be a default wallet policy for swap feature\n");
SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_NONDEFAULT_POLICY);
finalize_exchange_sign_transaction(false);
+#endif
}
// No external inputs allowed
@@ -54,11 +59,17 @@ bool __attribute__((noinline)) execute_swap_checks(dispatcher_context_t *dc,
}
if (st->warnings.missing_nonwitnessutxo || st->warnings.non_default_sighash) {
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Fuzzing repair: clear warnings so the swap-validation tail runs.
+ st->warnings.missing_nonwitnessutxo = false;
+ st->warnings.non_default_sighash = false;
+#else
// Do not allow transactions with missing non-witness utxos or non-default sighash flags
PRINTF(
"Missing non-witness utxo or non-default sighash flags are not allowed during swaps\n");
SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_MISSING_NONWITNESSUTXO);
finalize_exchange_sign_transaction(false);
+#endif
}
uint64_t fee = st->inputs_total_amount - st->outputs.total_amount;
@@ -82,9 +93,23 @@ bool __attribute__((noinline)) execute_swap_checks(dispatcher_context_t *dc,
swap_dest_idx = 1;
if (st->n_external_outputs != 2) {
+#ifdef FUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION
+ // Fuzzing repair: the exact "2 external outputs" count depends on
+ // mocked change-classification and is rarely hit by mutation. Relax to
+ // >=1 so the OP_RETURN parser below still runs; swap_dest_idx=0 keeps the
+ // later output_scripts[] access in-bounds. Downstream address/hash/fee
+ // checks fail naturally, so findings past here need production-reachability
+ // triage.
+ if (st->n_external_outputs < 1) {
+ SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_WRONG_N_OF_OUTPUTS);
+ finalize_exchange_sign_transaction(false);
+ }
+ swap_dest_idx = 0;
+#else
PRINTF("Cross-chain swap transaction must have exactly 2 external outputs\n");
SEND_SW_EC(dc, SW_FAIL_SWAP, EC_SWAP_ERROR_WRONG_METHOD_WRONG_N_OF_OUTPUTS);
finalize_exchange_sign_transaction(false);
+#endif
}
uint8_t *opreturn_script = st->outputs.output_scripts[0];Why this scored 20/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.