Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
[package]
name = "rustreexo-fuzz"
version = "0.0.0"
publish = false
edition = "2021"

[package.metadata]
cargo-fuzz = true

# Keep the fuzz crate out of the main package's build; it is its own
# workspace so the profile settings below are honored.
[workspace]

[dependencies]
libfuzzer-sys = "0.4"
arbitrary = { version = "1", features = ["derive"] }
rustreexo = { path = ".." }

[[bin]]
name = "stump_model"
path = "fuzz_targets/stump_model.rs"
test = false
doc = false
bench = false

[[bin]]
name = "deserialize"
path = "fuzz_targets/deserialize.rs"
test = false
doc = false
bench = false

[[bin]]
name = "proof_corruption"
path = "fuzz_targets/proof_corruption.rs"
test = false
doc = false
bench = false

# Fuzz builds must panic on arithmetic overflow and debug assertions:
# silent wrapping in position math is itself a bug class we want to catch.
[profile.release]
debug = true
debug-assertions = true
overflow-checks = true
62 changes: 62 additions & 0 deletions fuzz/fuzz_targets/deserialize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! Deserialization robustness fuzz target.
//!
//! Feeds arbitrary bytes to every public deserializer. None of them may
//! panic, abort on allocation, or overflow the stack; malformed input must
//! produce a clean error. Successful parses must round-trip.
//!
//! Expected early crashes (known Tier 0 findings; triage before long runs):
//! * `Proof::deserialize`: `Vec::with_capacity` on an attacker-controlled
//! u64 length => capacity-overflow panic / OOM (src/proof/mod.rs:462-474).
//! * `Pollard` / `MemForest`: unbounded recursion on nested input =>
//! stack overflow (src/pollard/mod.rs:261-302, src/mem_forest/mod.rs:132-180).
//! * `Stump::modify` on a deserialized stump whose roots don't match
//! `popcount(leaves)` => panic; gated by EXERCISE_STATE below.
#![no_main]

use libfuzzer_sys::fuzz_target;
use rustreexo::mem_forest::MemForest;
use rustreexo::node_hash::BitcoinNodeHash;
use rustreexo::pollard::Pollard;
use rustreexo::proof::Proof;
use rustreexo::stump::Stump;

/// Also exercise the state machine on successfully deserialized stumps.
/// Known to trip on malformed stumps; set to false after triage to keep
/// fuzzing for other bugs.
const EXERCISE_STATE: bool = true;

fn one_leaf() -> BitcoinNodeHash {
BitcoinNodeHash::from([0x42; 32])
}

fuzz_target!(|data: &[u8]| {
if let Ok(p) = Proof::<BitcoinNodeHash>::deserialize(data) {
let mut buf = Vec::new();
p.serialize(&mut buf)
.expect("serialize of parsed proof must succeed");
let p2 = Proof::<BitcoinNodeHash>::deserialize(&buf[..])
.expect("re-parse of own serialization must succeed");
assert_eq!(p, p2, "proof round-trip mismatch");
}

if let Ok(s) = Stump::<BitcoinNodeHash>::deserialize(data) {
let mut buf = Vec::new();
s.serialize(&mut buf)
.expect("serialize of parsed stump must succeed");
let s2 = Stump::<BitcoinNodeHash>::deserialize(&buf[..])
.expect("re-parse of own serialization must succeed");
assert_eq!(s, s2, "stump round-trip mismatch");

if EXERCISE_STATE {
// Malformed stumps must produce errors, never panics.
let _ = s.modify(&[one_leaf()], &[], &Proof::default());
let _ = s.modify(&[], &[], &Proof::default());
let _ = s.verify(&Proof::default(), &[]);
}
}

// Deeply nested / malformed input must be rejected without stack
// overflow or panics (both parsers are recursive).
let _ = Pollard::<BitcoinNodeHash>::deserialize(&mut &data[..]);
let _ = MemForest::<BitcoinNodeHash>::deserialize(&data[..]);
});
139 changes: 139 additions & 0 deletions fuzz/fuzz_targets/proof_corruption.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
//! Proof-corruption / soundness fuzz target.
//!
//! Builds a valid accumulator state, obtains a VALID deletion proof from the
//! `MemForest` oracle, applies one fuzzed corruption, then feeds the result
//! to `Stump::verify` and `Stump::modify` — the exact entry points Floresta
//! uses for peer-supplied proofs.
//!
//! Properties asserted:
//! * never panic (overflow-checks enabled; attacker-controlled positions
//! such as u64::MAX must be rejected, not crash),
//! * SOUNDNESS: a proof whose deletion hash was replaced by a non-member,
//! or with a bit-flipped proof hash, must not verify and must not modify
//! state.
//!
//! Note: the soundness assert currently reproduces the Tier 0 fail-open
//! finding in `Proof::verify` (src/proof/mod.rs:336-361). Set
//! `ENFORCE_SOUNDNESS = false` (or fix the bug) for long runs.
#![no_main]

use libfuzzer_sys::arbitrary::Arbitrary;
use libfuzzer_sys::fuzz_target;
use rustreexo::mem_forest::MemForest;
use rustreexo::node_hash::BitcoinNodeHash;
use rustreexo::proof::Proof;
use rustreexo::stump::Stump;

const ENFORCE_SOUNDNESS: bool = true;

/// Deterministic, unique, non-sentinel leaf hash for a counter value.
fn leaf(counter: u64) -> BitcoinNodeHash {
let mut bytes = [0u8; 32];
bytes[..8].copy_from_slice(&counter.to_le_bytes());
bytes[8..16].copy_from_slice(&(!counter).to_be_bytes());
bytes[16] = 0xa5;
BitcoinNodeHash::from(bytes)
}

#[derive(Debug, Arbitrary)]
enum Corruption {
/// Bit-flip one proof hash.
FlipHash { idx: u8, xor: u8 },
/// Replace one target with an arbitrary position (biased to edge values).
ReplaceTarget { idx: u8, pos: u64 },
/// Drop some proof hashes.
Truncate { keep: u8 },
/// Duplicate a target.
DupTarget { idx: u8 },
/// Replace one deletion hash with a non-member hash.
BogusDelHash { idx: u8, fresh: u64 },
}

#[derive(Debug, Arbitrary)]
struct Input {
n_leaves: u8,
second_tree: bool,
corruption: Corruption,
}

fuzz_target!(|input: Input| {
let n = 2 + (input.n_leaves % 31) as usize; // 2..=32 leaves
let leaves: Vec<_> = (0..n as u64).map(leaf).collect();

let (stump, _) = Stump::new()
.modify(&leaves, &[], &Proof::default())
.expect("setup add");
let mut forest = MemForest::new();
forest.modify(&leaves, &[]).expect("oracle setup");

// One deletion from the first leaf; optionally a second one from the
// last leaf (usually a different Merkle tree => multi-root proof).
let mut dels = vec![leaves[0]];
if input.second_tree && n > 2 {
dels.push(leaves[n - 1]);
}

let proof = forest.prove(&dels).expect("oracle must prove live leaves");
assert_eq!(
stump.verify(&proof, &dels),
Ok(true),
"LIVENESS: valid proof rejected by Stump"
);

let mut corrupted = proof.clone();
let mut corrupted_dels = dels.clone();
// Set when the corruption is guaranteed to invalidate the proof.
let mut expect_invalid = false;

match input.corruption {
Corruption::FlipHash { idx, xor } => {
if corrupted.hashes.is_empty() || xor == 0 {
return;
}
let i = idx as usize % corrupted.hashes.len();
if let BitcoinNodeHash::Some(mut inner) = corrupted.hashes[i] {
inner[0] ^= xor;
corrupted.hashes[i] = BitcoinNodeHash::from(inner);
expect_invalid = true;
}
}
Corruption::ReplaceTarget { idx, pos } => {
if corrupted.targets.is_empty() {
return;
}
let biased = match pos % 4 {
0 => pos,
1 => u64::MAX,
2 => stump.leaves.saturating_add(pos % 64), // just past the end
_ => pos % stump.leaves.max(1), // in-range, wrong pairing
};
let i = idx as usize % corrupted.targets.len();
corrupted.targets[i] = biased;
}
Corruption::Truncate { keep } => {
let keep = keep as usize % (corrupted.hashes.len() + 1);
corrupted.hashes.truncate(keep);
}
Corruption::DupTarget { idx } => {
if corrupted.targets.is_empty() {
return;
}
let t = corrupted.targets[idx as usize % corrupted.targets.len()];
corrupted.targets.push(t);
}
Corruption::BogusDelHash { idx, fresh } => {
let i = idx as usize % corrupted_dels.len();
corrupted_dels[i] = leaf(1_000_000 + fresh); // guaranteed non-member
expect_invalid = true;
}
}

// These calls must never panic, whatever the corruption was.
let v = stump.verify(&corrupted, &corrupted_dels);
let m = stump.modify(&[], &corrupted_dels, &corrupted);

if ENFORCE_SOUNDNESS && expect_invalid {
assert_ne!(v, Ok(true), "SOUNDNESS: corrupted proof accepted by verify");
assert!(m.is_err(), "SOUNDNESS: corrupted proof accepted by modify");
}
});
Loading