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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user