From 292cc4651402bb0d207a88a4b9dae5ccab4b7c5a Mon Sep 17 00:00:00 2001 From: Erick Cestari Date: Thu, 20 Aug 2026 15:19:21 -0300 Subject: [PATCH] Keep a valid address in pjuri fuzz mutations A bitcoin address is checksummed, so libFuzzer cannot construct one. Unlike a character-set check, which it learns from the comparisons it traces, a checksum gives it no way to turn the constant it needs into input bytes, so every input was rejected before the payjoin parameters were read and the body of the target never ran. The custom mutator keeps a parseable address, substituting a canonical one when the input carries none, and spends the mutation budget on the query instead. A quarter of mutations still go through untouched to keep the reject paths covered. Over 60s from an empty corpus, coverage rises from 293 to 1433 edges. Drop the TypeId assertions along the way: they compare compile-time constants, so they cannot fail, and their early returns would have silently disabled the rest of the target. --- fuzz/fuzz_targets/uri/deserialize_pjuri.rs | 103 +++++++++++++++------ 1 file changed, 77 insertions(+), 26 deletions(-) diff --git a/fuzz/fuzz_targets/uri/deserialize_pjuri.rs b/fuzz/fuzz_targets/uri/deserialize_pjuri.rs index 2d15acc28..7e64822d4 100644 --- a/fuzz/fuzz_targets/uri/deserialize_pjuri.rs +++ b/fuzz/fuzz_targets/uri/deserialize_pjuri.rs @@ -1,39 +1,90 @@ #![no_main] -use std::any::{Any, TypeId}; - -use libfuzzer_sys::fuzz_target; +use libfuzzer_sys::{fuzz_mutator, fuzz_target, fuzzer_mutate}; +use payjoin::bitcoin::address::{Address, NetworkUnchecked}; use payjoin::Uri; +const SCHEME: &str = "bitcoin:"; + +/// One canonical address per script type, so the mutator keeps every type +/// the target inspects in rotation. +const ADDRESSES: [&str; 4] = [ + "12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX", + "3EktnHQD7RiAE6uzMj2ZifT9YgRrkSgzQX", + "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", + "bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqzk5jj0", +]; + +/// Query handed to an input that carries none, so the first mutation +/// already lands on the payjoin parameters. +const DEFAULT_QUERY: &str = "amount=1&pj=https://example.com&pjos=0"; + +/// Share of mutations left to libFuzzer untouched. Everything the mutator +/// emits parses as a BIP 21 URI, so without a raw share the parser's reject +/// paths, and non-UTF-8 input, stop being covered at all. +const RAW_MUTATION_IN: u32 = 4; + +/// Split `bitcoin:
?`, substituting a canonical address +/// whenever the input does not carry one that parses. +/// +/// A bitcoin address is checksummed, so libFuzzer cannot construct one: +/// unlike a character-set check, which it learns from the comparisons it +/// traces, a checksum gives it no way to turn the constant it needs into +/// input bytes. Without help every input is rejected before the payjoin +/// parameters are ever read. Substituting an address here bootstraps the +/// target from an empty corpus, and re-emitting an intact one keeps +/// mutations from falling straight back out of the parser. +fn split_uri(input: &str, seed: u32) -> (&str, &str) { + let rest = input.strip_prefix(SCHEME).unwrap_or_default(); + let (address, query) = match rest.find('?') { + Some(i) => (&rest[..i], &rest[i + 1..]), + None => (rest, DEFAULT_QUERY), + }; + + if address.parse::>().is_ok() { + (address, query) + } else { + (ADDRESSES[seed as usize % ADDRESSES.len()], query) + } +} + +// Spend the mutation budget on the query, where the payjoin parameters +// live, rather than on the address. +fuzz_mutator!(|data: &mut [u8], size: usize, max_size: usize, seed: u32| { + if seed.is_multiple_of(RAW_MUTATION_IN) { + return fuzzer_mutate(data, size, max_size); + } + + let input = std::str::from_utf8(&data[..size]).unwrap_or_default(); + let (address, query) = split_uri(input, seed); + let prefix = format!("{SCHEME}{address}?"); + let mut buf = query.as_bytes().to_vec(); + + let query_max = max_size.saturating_sub(prefix.len()); + if query_max == 0 { + return fuzzer_mutate(data, size, max_size); + } + let query_len = buf.len().min(query_max); + buf.resize(query_max, 0); + let query_len = fuzzer_mutate(&mut buf, query_len, query_max); + + data[..prefix.len()].copy_from_slice(prefix.as_bytes()); + data[prefix.len()..prefix.len() + query_len].copy_from_slice(&buf[..query_len]); + prefix.len() + query_len +}); + fn do_test(data: &[u8]) { if let Ok(uri_str) = std::str::from_utf8(data) { let pj_uri = match Uri::try_from(uri_str.to_string()) { Ok(uri) => uri.assume_checked(), Err(_) => return, }; - if !pj_uri.address().is_spend_standard() { - return; - } - - if let Some(label) = pj_uri.label() { - if TypeId::of::() != label.type_id() { - return; - } - }; - if let Some(message) = pj_uri.message() { - if TypeId::of::() != message.type_id() { - return; - } - }; - let extras = match pj_uri.check_pj_supported() { - Ok(res) => res, - Err(_) => return, - }; - assert!( - TypeId::of::() - == extras.extras().output_substitution().type_id() - ); - assert!(TypeId::of::() == extras.extras().endpoint().type_id()) + let _ = pj_uri.address().is_spend_standard(); + let _ = pj_uri.label(); + let _ = pj_uri.message(); + let Ok(pj_extras) = pj_uri.check_pj_supported() else { return }; + let _ = pj_extras.extras().output_substitution(); + let _ = pj_extras.extras().endpoint(); } }