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
140 changes: 140 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# SPDX-License-Identifier: MIT OR Apache-2.0

name: Fuzz Tests

on:
push:
pull_request:

env:
CARGO_TERM_COLOR: always
CARGO_FUZZ_VERSION: 0.13.1
APT_CONFIG: |
Dir::Cache "./.apt-cache";
Dir::Cache::archives "./.apt-cache/archives";
Dir::State "./.apt-state";
Dir::State::lists "./.apt-state/lists/";

permissions: {}

jobs:
fuzz:
name: Run Fuzzing Tests
runs-on: ubuntu-latest

steps:
- name: Checkout Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Install Nightly Rust Toolchain
run: |
rustup toolchain install nightly --profile minimal --no-self-update
rustup default nightly

# Calculate cache keys
- name: Generate cache keys
run: |
YEAR=$(date +%Y)
WEEK=$(date +%U)
# Use '10#' to always treat week number as base-10 (avoids octal when number has a leading zero)
BIWEEK=$(( (10#$WEEK + 1) / 2 ))
echo "CACHE_VERSION=${YEAR}(${BIWEEK})" >> $GITHUB_ENV

# Hash of all files that could affect the build
HASH=$(echo "${{ hashFiles('Cargo-minimal.lock', 'Cargo-recent.lock', 'Cargo.toml', '.github/workflows/**') }}")
echo "BUILD_HASH=${HASH}" >> $GITHUB_ENV

# Hash of apt packages we need
APT_PACKAGES="build-essential cmake clang"
echo "APT_HASH=$(echo $APT_PACKAGES | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV
shell: bash

# Restore Rust build cache
- name: Restore Rust cache
id: cache-rust
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
~/.cargo/bin/cargo-fuzz
target/
fuzz/target/
key: ${{ runner.os }}-cargo-${{ env.CACHE_VERSION }}-fuzz${{ env.CARGO_FUZZ_VERSION }}-${{ env.BUILD_HASH }}
restore-keys: |
${{ runner.os }}-cargo-${{ env.CACHE_VERSION }}-fuzz${{ env.CARGO_FUZZ_VERSION }}-
${{ runner.os }}-cargo-${{ env.CACHE_VERSION }}-
${{ runner.os }}-cargo-

# Restore apt packages cache
- name: Restore apt cache
id: cache-apt
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
./.apt-cache
./.apt-state
key: ${{ runner.os }}-apt-${{ env.APT_HASH }}

- name: Install Boost
run: |
sudo apt-get update
sudo apt-get install -y libboost-all-dev

# Install system dependencies only if cache miss
- name: Install libfuzzer dependencies
if: steps.cache-apt.outputs.cache-hit != 'true'
run: |
mkdir -p ./.apt-cache/archives ./.apt-state/lists
sudo -E apt-get update && sudo -E apt-get install -y build-essential cmake clang

# Install cargo-fuzz only if not found in cache
- name: Install cargo-fuzz
if: steps.cache-rust.outputs.cache-hit != 'true'
run: cargo +nightly install cargo-fuzz --version ${{ env.CARGO_FUZZ_VERSION }} --force

# Run fuzzing tests
- name: Run fuzzing tests
run: |
cd fuzz
echo "Available fuzz targets:"
cargo fuzz list
for target in $(cargo fuzz list); do
echo "Running fuzz target: $target"
CARGO_PROFILE_RELEASE_LTO=false cargo +nightly fuzz run $target -- -max_total_time=60
done

# Save Rust cache if there was no exact match
- name: Save Rust cache
if: success() && steps.cache-rust.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
~/.cargo/bin/cargo-fuzz
target/
fuzz/target/
key: ${{ steps.cache-rust.outputs.cache-primary-key }}

# Save apt cache if there was no exact match
- name: Save apt cache
if: success() && steps.cache-apt.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
./.apt-cache
./.apt-state
key: ${{ runner.os }}-apt-${{ env.APT_HASH }}

# Upload artifacts (if crashes are found)
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: fuzz-artifacts
path: fuzz/artifacts
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");
}
});
Loading
Loading