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
2 changes: 2 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
artifacts/
corpus/
38 changes: 38 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[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 = "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
48 changes: 48 additions & 0 deletions fuzz/fuzz_targets/deserialize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//! 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.
//!
#![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;

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");

// 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[..]);
});
161 changes: 161 additions & 0 deletions fuzz/fuzz_targets/proof_corruption.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
//! 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.
//!
#![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;

/// 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)]
/// Corruption operations applied to a proof.
///
/// To exercise our crypto primitives, we corrupt an otherwise valid proof, making it invalid by a
/// small factor. We then make sure that our code will detect and reject such fraudulent proof,
/// without panicking. This enum contains all possible corruptions we use.
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)]
/// An input to our fuzz target.
struct Input {
/// How many leaves we should add to our fuzzer
n_leaves: u8,

/// Whether we should delete from one more tree.
second_tree: bool,

/// The corruption we will apply to this input
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 provide valid proofs");
assert_eq!(
stump.verify(&proof, &dels),
Ok(true),
"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);

// this won't corrupt anything
if corrupted.hashes.len() <= keep.into() {
return;
}

corrupted.hashes.truncate(keep);
expect_invalid = true;
}
Corruption::DupTarget { idx } => {
if corrupted.targets.is_empty() {
return;
}
let t = corrupted.targets[idx as usize % corrupted.targets.len()];
corrupted.targets.push(t);
expect_invalid = true;
}
Corruption::BogusDelHash { idx, fresh } => {
let i = idx as usize % corrupted_dels.len();
corrupted_dels[i] = leaf(1_000_000u64.saturating_add(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 expect_invalid {
assert_ne!(v, Ok(true), "corrupted proof accepted by verify");
assert!(m.is_err(), "corrupted proof accepted by modify");
}
});
48 changes: 44 additions & 4 deletions src/mem_forest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,23 @@ impl<Hash: AccumulatorHash> Node<Hash> {
ancestor: Option<Rc<Node<Hash>>>,
reader: &mut R,
index: &mut HashMap<Hash, Weak<Node<Hash>>>,
depth: u8,
) -> io::Result<Rc<Node<Hash>>> {
// The forest has at most 64 rows, so a serialized node can never be
// deeper than that. Anything deeper is malformed input; reject it
// instead of recursing until the stack overflows.
if depth > MAX_FOREST_ROWS + 1 {
return Err(io::Error::from(io::ErrorKind::InvalidData));
}

let mut ty = [0u8; 8];
reader.read_exact(&mut ty)?;
let data = Hash::read(reader)?;

let ty = match u64::from_le_bytes(ty) {
0 => NodeType::Branch,
1 => NodeType::Leaf,
_ => panic!("Invalid node type"),
_ => return Err(io::Error::from(io::ErrorKind::InvalidData)),
};
if ty == NodeType::Leaf {
let leaf = Rc::new(Node {
Expand All @@ -162,8 +170,8 @@ impl<Hash: AccumulatorHash> Node<Hash> {
right: RefCell::new(None),
});
if !data.is_empty() {
let left = _read_one(Some(node.clone()), reader, index)?;
let right = _read_one(Some(node.clone()), reader, index)?;
let left = _read_one(Some(node.clone()), reader, index, depth + 1)?;
let right = _read_one(Some(node.clone()), reader, index, depth + 1)?;
node.left.replace(Some(left));
node.right.replace(Some(right));
}
Expand All @@ -179,7 +187,7 @@ impl<Hash: AccumulatorHash> Node<Hash> {
Ok(node)
}
let mut index = HashMap::with_hasher(Default::default());
let root = _read_one(None, reader, &mut index)?;
let root = _read_one(None, reader, &mut index, 0)?;
Ok((root, index))
}

Expand Down Expand Up @@ -1000,6 +1008,38 @@ mod test {
);
}

#[test]
fn test_deserialize_rejects_invalid_node_type() {
// A node type other than 0 (branch) or 1 (leaf) must produce an error,
// not a panic.
let mut data = Vec::new();
data.extend_from_slice(&1u64.to_le_bytes()); // leaves = 1
data.extend_from_slice(&1u64.to_le_bytes()); // roots_len = 1
data.extend_from_slice(&42u64.to_le_bytes()); // invalid node type
data.extend_from_slice(&[0u8; 32]); // hash
let result = MemForest::<BitcoinNodeHash>::deserialize(&data[..]);
assert!(result.is_err());
}

#[test]
fn test_deserialize_rejects_deep_nesting() {
// Craft input with deeply nested branch nodes; must not stack-overflow.
let mut data = Vec::new();
data.extend_from_slice(&1u64.to_le_bytes()); // leaves = 1
data.extend_from_slice(&1u64.to_le_bytes()); // roots_len = 1
// 200 nested branch nodes (type=0 + non-empty hash so children are read)
for _ in 0..200 {
data.extend_from_slice(&0u64.to_le_bytes()); // branch
data.extend_from_slice(&[0x42u8; 32]); // non-empty hash
}
// Terminal leaf
data.extend_from_slice(&1u64.to_le_bytes());
data.extend_from_slice(&[0x42u8; 32]);

let result = MemForest::<BitcoinNodeHash>::deserialize(&data[..]);
assert!(result.is_err());
}

#[test]
fn test_serialize_one() {
let hashes = get_hash_vec_of(&[0, 1, 2, 3, 4, 5, 6, 7]);
Expand Down
39 changes: 37 additions & 2 deletions src/pollard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,22 @@ impl<Hash: AccumulatorHash> PollardNode<Hash> {
ancestor: Option<Weak<Self>>,
leaf_map: &mut HashMap<Hash, Weak<Self>>,
) -> Result<Rc<Self>, PollardError<Hash>> {
Self::deserialize_inner(reader, ancestor, leaf_map, 0)
}

fn deserialize_inner<R: Read>(
reader: &mut R,
ancestor: Option<Weak<Self>>,
leaf_map: &mut HashMap<Hash, Weak<Self>>,
depth: u8,
) -> Result<Rc<Self>, PollardError<Hash>> {
// The forest has at most 64 rows, so a serialized node can never be deeper
// than that. Anything deeper is malformed input; reject it instead of
// recursing until the stack overflows.
if depth > MAX_FOREST_ROWS + 1 {
return Err(PollardError::InvalidProof);
}

let mut is_leaf = [0u8; 1];
reader.read_exact(&mut is_leaf)?;

Expand Down Expand Up @@ -293,8 +309,8 @@ impl<Hash: AccumulatorHash> PollardNode<Hash> {

let node_weak = Rc::downgrade(&node);

let left = Self::deserialize(reader, Some(node_weak.clone()), leaf_map)?;
let right = Self::deserialize(reader, Some(node_weak), leaf_map)?;
let left = Self::deserialize_inner(reader, Some(node_weak.clone()), leaf_map, depth + 1)?;
let right = Self::deserialize_inner(reader, Some(node_weak), leaf_map, depth + 1)?;

node.left_niece.replace(Some(left));
node.right_niece.replace(Some(right));
Expand Down Expand Up @@ -1286,6 +1302,25 @@ mod tests {
use crate::node_hash::BitcoinNodeHash;
use crate::util::hash_from_u8;

#[test]
fn test_deserialize_rejects_deep_nesting() {
// Craft input with many nested branch nodes; must not stack-overflow.
let mut data = Vec::new();
data.extend_from_slice(&1u64.to_be_bytes()); // leaves = 1
data.push(1u8); // root marker = present
// 200 nested branch nodes (is_leaf=0 + 32-byte hash each)
for _ in 0..200 {
data.push(0u8); // is_leaf = false
data.extend_from_slice(&[0x42u8; 32]);
}
// Terminal leaf
data.push(1u8);
data.extend_from_slice(&[0x42u8; 32]);

let result = Pollard::<BitcoinNodeHash>::deserialize(&mut &data[..]);
assert!(result.is_err());
}

#[test]
fn test_ser_rtt() {
let mut p = Pollard::<BitcoinNodeHash>::new();
Expand Down
Loading
Loading