feat(fuzz): add SharpFuzz-based fuzzing for untrusted-input parsers

New tests/PalladiumWallet.Fuzz project: one target per parser that
consumes untrusted input (block headers, Merkle proofs, peer lists,
wallet files, user-pasted mnemonics/keys/addresses/amounts), each
encoding the parser's documented error contract - any exception beyond
that contract is a finding, reported as a crash.

Three ways to run: the seed corpus (with regression inputs for every
crash found so far, including the two fixed in prior commits) replays
automatically inside dotnet test via FuzzCorpusTests, so a fixed
contract violation can't silently return; a built-in random-mutation
mode needs no external tooling (dotnet run -- <target> --random N);
and fuzz.sh drives real coverage-guided campaigns via afl++ +
SharpFuzz instrumentation for pre-release or post-parser-change runs.
This commit is contained in:
2026-07-07 22:05:08 +02:00
parent a264669151
commit cdede17683
36 changed files with 683 additions and 66 deletions
+141
View File
@@ -0,0 +1,141 @@
using System.Text;
using System.Text.Json;
using NBitcoin;
using PalladiumWallet.Core.Chain;
using PalladiumWallet.Core.Crypto;
using PalladiumWallet.Core.Net;
using PalladiumWallet.Core.Spv;
using PalladiumWallet.Core.Storage;
using PalladiumWallet.Core.Wallet;
namespace PalladiumWallet.Fuzz;
/// <summary>
/// Fuzz targets over the parsers that consume untrusted input (server responses,
/// wallet files, user-pasted keys/addresses/amounts). Each target enforces the
/// parser's error contract: the exception types documented as its failure mode
/// are swallowed, anything else escapes and is reported as a crash by the fuzzer.
/// </summary>
public static class FuzzTargets
{
public static readonly IReadOnlyDictionary<string, Action<byte[]>> All =
new Dictionary<string, Action<byte[]>>
{
["header"] = Header,
["merkle"] = Merkle,
["slip132"] = Slip132Keys,
["bip39"] = Bip39Mnemonic,
["address"] = Address,
["coinamount"] = CoinAmountParse,
["walletdoc"] = WalletDoc,
["encfile"] = EncFile,
["peers"] = Peers,
};
/// <summary>Runs one input through a named target (used by replay and the regression test).</summary>
public static void Run(string target, byte[] data) => All[target](data);
private static string Utf8(byte[] data) => Encoding.UTF8.GetString(data);
// 80-byte block headers arrive from the server both as raw bytes and as hex.
// Contract: exactly 80 bytes never throws; any other length is ArgumentException;
// the hex overload may also reject non-hex text with FormatException.
private static void Header(byte[] data)
{
try { BlockHeaderInfo.Parse(data); }
catch (ArgumentException) when (data.Length != BlockHeaderInfo.Size) { }
try { BlockHeaderInfo.Parse(Utf8(data)); }
catch (FormatException) { }
catch (ArgumentException) { }
}
// Merkle proof verification runs on server-supplied txid/pos/branch.
// Contract: never throws, for any position (including negative) and branch.
private static void Merkle(byte[] data)
{
if (data.Length < 68)
return;
var txid = new uint256(data.AsSpan(0, 32));
var position = BitConverter.ToInt32(data, 32);
var root = new uint256(data.AsSpan(36, 32));
var branch = new List<uint256>();
for (var offset = 68; offset + 32 <= data.Length; offset += 32)
branch.Add(new uint256(data.AsSpan(offset, 32)));
MerkleProof.Verify(txid, position, branch, root);
}
// SLIP-132 extended keys are pasted by the user on import.
// Contract: the Try* pair never throws, on any profile.
private static void Slip132Keys(byte[] data)
{
var text = Utf8(data);
foreach (var profile in new[] { ChainProfiles.Mainnet, ChainProfiles.Testnet, ChainProfiles.Regtest })
{
Slip132.TryDecodePublic(text, profile, out _, out _);
Slip132.TryDecodePrivate(text, profile, out _, out _);
}
}
// Mnemonics are pasted by the user on restore. Contract: TryParse never throws,
// with auto-detected and explicit wordlist language alike.
private static void Bip39Mnemonic(byte[] data)
{
var text = Utf8(data);
Bip39.TryParse(text, out _);
if (data.Length > 0)
Bip39.TryParse(text, out _, (MnemonicLanguage)(data[0] % 8));
}
// Recipient addresses are pasted by the user (or scanned from a QR).
// Contract: NBitcoin rejects anything invalid with FormatException.
private static void Address(byte[] data)
{
try { BitcoinAddress.Create(Utf8(data), PalladiumNetworks.Mainnet); }
catch (FormatException) { }
}
// Amounts are typed by the user in the currently selected unit.
// Contract: TryParse* never throws for any of the official units.
private static void CoinAmountParse(byte[] data)
{
var text = Utf8(data);
foreach (var unit in CoinAmount.Units)
CoinAmount.TryParseIn(text, unit, out _);
CoinAmount.TryParseCoins(text, out _);
}
// The plaintext wallet document is read from disk at every open.
// Contract: malformed JSON or schema is JsonException/InvalidDataException.
private static void WalletDoc(byte[] data)
{
try { WalletDocument.FromJson(Utf8(data)); }
catch (JsonException) { }
catch (InvalidDataException) { }
}
// The encrypted container wraps the document on disk; a tampered file must
// fail with the two typed exceptions, never a raw parser error, and
// IsEncrypted (the format sniffer) must never throw at all.
private static void EncFile(byte[] data)
{
var text = Utf8(data);
EncryptedFile.IsEncrypted(text);
try { EncryptedFile.Decrypt(text, "fuzz-password"); }
catch (WrongPasswordException) { }
catch (InvalidDataException) { }
}
// server.peers.subscribe responses are attacker-controlled JSON.
// Contract: any well-formed JSON of any shape parses to a (possibly empty)
// peer list without throwing.
private static void Peers(byte[] data)
{
JsonDocument doc;
try { doc = JsonDocument.Parse(data); }
catch (JsonException) { return; }
using (doc)
ElectrumApi.ParsePeers(doc.RootElement);
}
}