diff --git a/Cargo.lock b/Cargo.lock index c05e45b24..a1f74fbac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1962,6 +1962,13 @@ dependencies = [ "soroban-sdk", ] +[[package]] +name = "test_fuzz_afl" +version = "27.0.5" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "test_generics" version = "27.0.5" diff --git a/Makefile b/Makefile index 20f959805..7f36fdbd1 100644 --- a/Makefile +++ b/Makefile @@ -46,6 +46,10 @@ build-test-wasms: fmt build-fuzz: cd tests/fuzz/fuzz && cargo +nightly fuzz check +# Builds the afl fuzz test. Requires cargo-afl, see tests/fuzz_afl/README.md. +build-fuzz-afl: + cd tests/fuzz_afl/fuzz && cargo afl build + readme: cd soroban-sdk \ && cargo +nightly rustdoc --features testutils -- -Zunstable-options -wjson \ diff --git a/soroban-sdk/src/testutils/arbitrary.rs b/soroban-sdk/src/testutils/arbitrary.rs index 4fa38d1d7..33531f3af 100644 --- a/soroban-sdk/src/testutils/arbitrary.rs +++ b/soroban-sdk/src/testutils/arbitrary.rs @@ -1,15 +1,17 @@ -//! Support for randomized testing of Soroban contracts, with [`cargo-fuzz`] or -//! [`proptest`]. +//! Support for randomized testing of Soroban contracts, with [`cargo-fuzz`], +//! [`cargo-afl`], or [`proptest`]. //! //! This module provides a pattern for generating Soroban contract types for the //! purpose of fuzzing and property testing Soroban contracts. It is focused on //! implementing the [`Arbitrary`] trait, which turns generated bytes into Rust -//! values: `cargo-fuzz` consumes it directly, and `proptest` through a bridge. +//! values: `cargo-fuzz` and `cargo-afl` consume it directly, and `proptest` +//! through a bridge. //! //! The examples in this module are written for `cargo-fuzz`. For generating the //! same values in a `proptest` property test, see the [`proptest`] module. //! //! [`cargo-fuzz`]: https://github.com/rust-fuzz/cargo-fuzz/ +//! [`cargo-afl`]: https://github.com/rust-fuzz/afl.rs/ //! [`Arbitrary`]: ::arbitrary::Arbitrary //! [`proptest`]: crate::testutils::proptest //! @@ -21,10 +23,10 @@ //! This module is only available when the "testutils" Cargo feature is defined. //! //! -//! ## About `cargo-fuzz` and `Arbitrary` +//! ## About `cargo-fuzz`, `cargo-afl`, and `Arbitrary` //! -//! In its basic operation `cargo-fuzz` fuzz generates raw bytes and feeds them -//! to a program-dependent fuzzer designed to exercise a program, in our case a +//! In its basic operation a fuzzer generates raw bytes and feeds them to a +//! program-dependent fuzz target designed to exercise a program, in our case a //! Soroban contract. //! //! `cargo-fuzz` programs declare their entry points with a macro: @@ -170,6 +172,85 @@ //! // fuzz the program based on the input //! }); //! ``` +//! +//! +//! ## Fuzzing with `cargo-afl` instead of `cargo-fuzz` +//! +//! Everything above applies unchanged when fuzzing with [`cargo-afl`], which +//! drives [AFL++] instead of libFuzzer: prototypes are generated by the same +//! [`Arbitrary`] trait, and are converted to contract types with the same +//! [`FromVal`] or [`IntoVal`] conversions. `cargo-afl` runs on stable Rust, +//! whereas `cargo-fuzz` requires a nightly compiler. +//! +//! [AFL++]: https://aflplus.plus +//! +//! Install the tooling, which builds AFL++ from source and so needs a C +//! compiler and LLVM available, and let AFL++ tune the machine for fuzzing: +//! +//! ```text +//! cargo install cargo-afl --locked +//! cargo afl system-config +//! ``` +//! +//! An AFL++ fuzz target is a crate with a `main` that calls the [`afl::fuzz!`] +//! macro, and it depends on the `arbitrary` crate directly, because that is +//! where the `Arbitrary` derive comes from and where `fuzz!` looks up the trait: +//! +//! ```toml +//! [dependencies] +//! afl = "0.18" +//! arbitrary = { version = "~1.3.0", features = ["derive"] } +//! soroban-sdk = { version = "*", features = ["testutils"] } +//! my-contract = { path = ".." } +//! ``` +//! +//! [`afl::fuzz!`]: https://docs.rs/afl/latest/afl/macro.fuzz.html +//! +//! ``` +//! # macro_rules! fuzz { +//! # (|$data:ident: $dty: ty| $body:block) => { }; +//! # } +//! use arbitrary::Arbitrary; +//! use soroban_sdk::testutils::arbitrary::SorobanArbitrary; +//! use soroban_sdk::{Address, Env, IntoVal}; +//! +//! #[derive(Arbitrary, Debug)] +//! struct TestInput { +//! deposit_amount: i128, +//! claim_address:
::Prototype, +//! } +//! +//! fn main() { +//! fuzz!(|input: TestInput| { +//! // Create the `Env` inside the closure, not outside: AFL++ reuses the +//! // process for many inputs, and state created outside the closure +//! // would leak from one input into the next. +//! let env = Env::default(); +//! let claim_address: Address = input.claim_address.into_val(&env); +//! // fuzz the contract based on the input +//! }); +//! } +//! ``` +//! +//! Build with `cargo afl build`, which is `cargo build` with the AFL++ +//! instrumentation added, and fuzz the resulting binary with an input directory +//! containing at least one seed input: +//! +//! ```text +//! cargo afl build +//! cargo afl fuzz -i in -o out target/debug/fuzz_target_1 +//! ``` +//! +//! Fuzz debug builds, at least at first: they keep integer overflow checks and +//! `debug_assert!`s enabled, and those catch bugs a release build allows. +//! +//! Crashing inputs are written to `out/default/crashes/`. The target reads an +//! input on stdin when it is not being driven by AFL++, so a crash is replayed +//! by feeding the file back in: +//! +//! ```text +//! RUST_BACKTRACE=1 ./target/debug/fuzz_target_1 < out/default/crashes/id:000000* +//! ``` /// A reexport of the `arbitrary` crate. /// diff --git a/tests-expanded/test_fuzz_afl_tests.rs b/tests-expanded/test_fuzz_afl_tests.rs new file mode 100644 index 000000000..f4db42280 --- /dev/null +++ b/tests-expanded/test_fuzz_afl_tests.rs @@ -0,0 +1,350 @@ +#![feature(prelude_import)] +#![no_std] +#[macro_use] +extern crate core; +#[prelude_import] +use core::prelude::rust_2021::*; +use soroban_sdk::{contract, contractimpl, U256}; +pub struct Contract; +///ContractArgs is a type for building arg lists for functions defined in "Contract". +pub struct ContractArgs; +///ContractClient is a client for calling the contract defined in "Contract". +pub struct ContractClient<'a> { + pub env: soroban_sdk::Env, + pub address: soroban_sdk::Address, + #[doc(hidden)] + set_auths: Option<&'a [soroban_sdk::xdr::SorobanAuthorizationEntry]>, + #[doc(hidden)] + mock_auths: Option<&'a [soroban_sdk::testutils::MockAuth<'a>]>, + #[doc(hidden)] + mock_all_auths: bool, + #[doc(hidden)] + allow_non_root_auth: bool, +} +impl<'a> ContractClient<'a> { + pub fn new(env: &soroban_sdk::Env, address: &soroban_sdk::Address) -> Self { + Self { + env: env.clone(), + address: address.clone(), + set_auths: None, + mock_auths: None, + mock_all_auths: false, + allow_non_root_auth: false, + } + } + /// Set authorizations in the environment which will be consumed by + /// contracts when they invoke `Address::require_auth` or + /// `Address::require_auth_for_args` functions. + /// + /// Requires valid signatures for the authorization to be successful. + /// To mock auth without requiring valid signatures, use `mock_auths`. + /// + /// See `soroban_sdk::Env::set_auths` for more details and examples. + pub fn set_auths(&self, auths: &'a [soroban_sdk::xdr::SorobanAuthorizationEntry]) -> Self { + Self { + env: self.env.clone(), + address: self.address.clone(), + set_auths: Some(auths), + mock_auths: self.mock_auths.clone(), + mock_all_auths: false, + allow_non_root_auth: false, + } + } + /// Mock authorizations in the environment which will cause matching invokes + /// of `Address::require_auth` and `Address::require_auth_for_args` to + /// pass. + /// + /// See `soroban_sdk::Env::set_auths` for more details and examples. + pub fn mock_auths(&self, mock_auths: &'a [soroban_sdk::testutils::MockAuth<'a>]) -> Self { + Self { + env: self.env.clone(), + address: self.address.clone(), + set_auths: self.set_auths.clone(), + mock_auths: Some(mock_auths), + mock_all_auths: false, + allow_non_root_auth: false, + } + } + /// Mock all calls to the `Address::require_auth` and + /// `Address::require_auth_for_args` functions in invoked contracts, + /// having them succeed as if authorization was provided. + /// + /// See `soroban_sdk::Env::mock_all_auths` for more details and + /// examples. + pub fn mock_all_auths(&self) -> Self { + Self { + env: self.env.clone(), + address: self.address.clone(), + set_auths: None, + mock_auths: None, + mock_all_auths: true, + allow_non_root_auth: false, + } + } + /// A version of `mock_all_auths` that allows authorizations that + /// are not present in the root invocation. + /// + /// Refer to `mock_all_auths` documentation for details and + /// prefer using `mock_all_auths` unless non-root authorization is + /// required. + /// + /// See `soroban_sdk::Env::mock_all_auths_allowing_non_root_auth` + /// for more details and examples. + pub fn mock_all_auths_allowing_non_root_auth(&self) -> Self { + Self { + env: self.env.clone(), + address: self.address.clone(), + set_auths: None, + mock_auths: None, + mock_all_auths: true, + allow_non_root_auth: true, + } + } +} +mod __contract_fn_set_registry { + use super::*; + extern crate std; + use std::collections::BTreeMap; + use std::sync::Mutex; + pub type F = soroban_sdk::testutils::ContractFunctionF; + static FUNCS: Mutex> = Mutex::new(BTreeMap::new()); + pub fn register(name: &'static str, func: &'static F) { + FUNCS.lock().unwrap().insert(name, func); + } + pub fn call( + name: &str, + env: soroban_sdk::Env, + args: &[soroban_sdk::Val], + ) -> Option { + let fopt: Option<&'static F> = FUNCS.lock().unwrap().get(name).map(|f| f.clone()); + fopt.map(|f| f(env, args)) + } +} +impl soroban_sdk::testutils::ContractFunctionRegister for Contract { + fn register(name: &'static str, func: &'static __contract_fn_set_registry::F) { + __contract_fn_set_registry::register(name, func); + } +} +#[doc(hidden)] +impl soroban_sdk::testutils::ContractFunctionSet for Contract { + fn call( + &self, + func: &str, + env: soroban_sdk::Env, + args: &[soroban_sdk::Val], + ) -> Option { + __contract_fn_set_registry::call(func, env, args) + } +} +impl Contract { + pub fn run(a: U256, b: U256) { + if a < b { + { + ::core::panicking::panic_fmt(format_args!("unexpected")); + } + } + } +} +#[doc(hidden)] +#[allow(non_snake_case)] +pub mod __Contract__run__spec { + #[doc(hidden)] + #[allow(non_snake_case)] + #[allow(non_upper_case_globals)] + pub static __SPEC_XDR_FN_RUN: [u8; 56usize] = super::Contract::spec_xdr_run(); +} +impl Contract { + #[allow(non_snake_case)] + pub const fn spec_xdr_run() -> [u8; 56usize] { + *b"\0\0\0\0\0\0\0\0\0\0\0\x03run\0\0\0\0\x02\0\0\0\0\0\0\0\x01a\0\0\0\0\0\0\x0c\0\0\0\0\0\0\0\x01b\0\0\0\0\0\0\x0c\0\0\0\0" + } +} +impl<'a> ContractClient<'a> { + pub fn run(&self, a: &U256, b: &U256) -> () { + use core::ops::Not; + let old_auth_manager = self + .env + .in_contract() + .not() + .then(|| self.env.host().snapshot_auth_manager().unwrap()); + { + if let Some(set_auths) = self.set_auths { + self.env.set_auths(set_auths); + } + if let Some(mock_auths) = self.mock_auths { + self.env.mock_auths(mock_auths); + } + if self.mock_all_auths { + if self.allow_non_root_auth { + self.env.mock_all_auths_allowing_non_root_auth(); + } else { + self.env.mock_all_auths(); + } + } + } + use soroban_sdk::{FromVal, IntoVal}; + let res = self.env.invoke_contract( + &self.address, + &{ + #[allow(deprecated)] + const SYMBOL: soroban_sdk::Symbol = soroban_sdk::Symbol::short("run"); + SYMBOL + }, + ::soroban_sdk::Vec::from_array( + &self.env, + [a.into_val(&self.env), b.into_val(&self.env)], + ), + ); + if let Some(old_auth_manager) = old_auth_manager { + self.env.host().set_auth_manager(old_auth_manager).unwrap(); + } + res + } + pub fn try_run( + &self, + a: &U256, + b: &U256, + ) -> Result< + Result<(), <() as soroban_sdk::TryFromVal>::Error>, + Result, + > { + use core::ops::Not; + let old_auth_manager = self + .env + .in_contract() + .not() + .then(|| self.env.host().snapshot_auth_manager().unwrap()); + { + if let Some(set_auths) = self.set_auths { + self.env.set_auths(set_auths); + } + if let Some(mock_auths) = self.mock_auths { + self.env.mock_auths(mock_auths); + } + if self.mock_all_auths { + if self.allow_non_root_auth { + self.env.mock_all_auths_allowing_non_root_auth(); + } else { + self.env.mock_all_auths(); + } + } + } + use soroban_sdk::{FromVal, IntoVal}; + let res = self.env.try_invoke_contract( + &self.address, + &{ + #[allow(deprecated)] + const SYMBOL: soroban_sdk::Symbol = soroban_sdk::Symbol::short("run"); + SYMBOL + }, + ::soroban_sdk::Vec::from_array( + &self.env, + [a.into_val(&self.env), b.into_val(&self.env)], + ), + ); + if let Some(old_auth_manager) = old_auth_manager { + self.env.host().set_auth_manager(old_auth_manager).unwrap(); + } + res + } +} +impl ContractArgs { + #[inline(always)] + #[allow(clippy::unused_unit)] + pub fn run<'i>(a: &'i U256, b: &'i U256) -> (&'i U256, &'i U256) { + (a, b) + } +} +#[doc(hidden)] +#[allow(non_snake_case)] +#[deprecated(note = "use `ContractClient::new(&env, &contract_id).run` instead")] +#[allow(deprecated)] +pub fn __Contract__run__invoke_raw( + env: soroban_sdk::Env, + arg_0: soroban_sdk::Val, + arg_1: soroban_sdk::Val, +) -> soroban_sdk::Val { + soroban_sdk::IntoValForContractFn::into_val_for_contract_fn( + ::run( + <_ as soroban_sdk::unwrap::UnwrapOptimized>::unwrap_optimized( + <_ as soroban_sdk::TryFromValForContractFn< + soroban_sdk::Env, + soroban_sdk::Val, + >>::try_from_val_for_contract_fn(&env, &arg_0), + ), + <_ as soroban_sdk::unwrap::UnwrapOptimized>::unwrap_optimized( + <_ as soroban_sdk::TryFromValForContractFn< + soroban_sdk::Env, + soroban_sdk::Val, + >>::try_from_val_for_contract_fn(&env, &arg_1), + ), + ), + &env, + ) +} +#[doc(hidden)] +#[allow(non_snake_case)] +#[deprecated(note = "use `ContractClient::new(&env, &contract_id).run` instead")] +pub fn __Contract__run__invoke_raw_slice( + env: soroban_sdk::Env, + args: &[soroban_sdk::Val], +) -> soroban_sdk::Val { + if args.len() != 2usize { + { + ::core::panicking::panic_fmt(format_args!( + "invalid number of input arguments: {0} expected, got {1}", + 2usize, + args.len(), + )); + }; + } + #[allow(deprecated)] + __Contract__run__invoke_raw(env, args[0usize], args[1usize]) +} +#[doc(hidden)] +#[allow(non_snake_case)] +#[deprecated(note = "use `ContractClient::new(&env, &contract_id).run` instead")] +pub extern "C" fn __Contract__run__invoke_raw_extern( + arg_0: soroban_sdk::Val, + arg_1: soroban_sdk::Val, +) -> soroban_sdk::Val { + #[allow(deprecated)] + __Contract__run__invoke_raw(soroban_sdk::Env::default(), arg_0, arg_1) +} +#[doc(hidden)] +#[allow(non_snake_case)] +#[allow(unused)] +fn __Contract____acba25512100f80b56fc3ccd14c65be55d94800cda77585c5f41a887e398f9be_ctor() { + #[allow(unsafe_code)] + { + #[link_section = ".init_array"] + #[used] + #[allow(non_upper_case_globals, non_snake_case)] + #[doc(hidden)] + static f: extern "C" fn() -> ::ctor::__support::CtorRetType = { + #[link_section = ".text.startup"] + #[allow(non_snake_case)] + extern "C" fn f() -> ::ctor::__support::CtorRetType { + unsafe { + __Contract____acba25512100f80b56fc3ccd14c65be55d94800cda77585c5f41a887e398f9be_ctor(); + }; + core::default::Default::default() + } + f + }; + } + { + ::register( + "run", + #[allow(deprecated)] + &__Contract__run__invoke_raw_slice, + ); + } +} +#[rustc_main] +#[coverage(off)] +#[doc(hidden)] +pub fn main() -> () { + extern crate test; + test::test_main_static(&[]) +} diff --git a/tests-expanded/test_fuzz_afl_wasm32v1-none.rs b/tests-expanded/test_fuzz_afl_wasm32v1-none.rs new file mode 100644 index 000000000..95d5497bf --- /dev/null +++ b/tests-expanded/test_fuzz_afl_wasm32v1-none.rs @@ -0,0 +1,137 @@ +#![feature(prelude_import)] +#![no_std] +#[macro_use] +extern crate core; +#[prelude_import] +use core::prelude::rust_2021::*; +use soroban_sdk::{contract, contractimpl, U256}; +pub struct Contract; +///ContractArgs is a type for building arg lists for functions defined in "Contract". +pub struct ContractArgs; +///ContractClient is a client for calling the contract defined in "Contract". +pub struct ContractClient<'a> { + pub env: soroban_sdk::Env, + pub address: soroban_sdk::Address, + #[doc(hidden)] + _phantom: core::marker::PhantomData<&'a ()>, +} +impl<'a> ContractClient<'a> { + pub fn new(env: &soroban_sdk::Env, address: &soroban_sdk::Address) -> Self { + Self { + env: env.clone(), + address: address.clone(), + _phantom: core::marker::PhantomData, + } + } +} +impl Contract { + pub fn run(a: U256, b: U256) { + if a < b { + { + ::core::panicking::panic_fmt(format_args!("unexpected")); + } + } + } +} +#[doc(hidden)] +#[allow(non_snake_case)] +pub mod __Contract__run__spec { + #[doc(hidden)] + #[allow(non_snake_case)] + #[allow(non_upper_case_globals)] + #[link_section = "contractspecv0"] + pub static __SPEC_XDR_FN_RUN: [u8; 56usize] = super::Contract::spec_xdr_run(); +} +impl Contract { + #[allow(non_snake_case)] + pub const fn spec_xdr_run() -> [u8; 56usize] { + *b"\0\0\0\0\0\0\0\0\0\0\0\x03run\0\0\0\0\x02\0\0\0\0\0\0\0\x01a\0\0\0\0\0\0\x0c\0\0\0\0\0\0\0\x01b\0\0\0\0\0\0\x0c\0\0\0\0" + } +} +impl<'a> ContractClient<'a> { + pub fn run(&self, a: &U256, b: &U256) -> () { + use core::ops::Not; + use soroban_sdk::{FromVal, IntoVal}; + let res = self.env.invoke_contract( + &self.address, + &{ + #[allow(deprecated)] + const SYMBOL: soroban_sdk::Symbol = soroban_sdk::Symbol::short("run"); + SYMBOL + }, + ::soroban_sdk::Vec::from_array( + &self.env, + [a.into_val(&self.env), b.into_val(&self.env)], + ), + ); + res + } + pub fn try_run( + &self, + a: &U256, + b: &U256, + ) -> Result< + Result<(), <() as soroban_sdk::TryFromVal>::Error>, + Result, + > { + use soroban_sdk::{FromVal, IntoVal}; + let res = self.env.try_invoke_contract( + &self.address, + &{ + #[allow(deprecated)] + const SYMBOL: soroban_sdk::Symbol = soroban_sdk::Symbol::short("run"); + SYMBOL + }, + ::soroban_sdk::Vec::from_array( + &self.env, + [a.into_val(&self.env), b.into_val(&self.env)], + ), + ); + res + } +} +impl ContractArgs { + #[inline(always)] + #[allow(clippy::unused_unit)] + pub fn run<'i>(a: &'i U256, b: &'i U256) -> (&'i U256, &'i U256) { + (a, b) + } +} +#[doc(hidden)] +#[allow(non_snake_case)] +#[deprecated(note = "use `ContractClient::new(&env, &contract_id).run` instead")] +#[allow(deprecated)] +pub fn __Contract__run__invoke_raw( + env: soroban_sdk::Env, + arg_0: soroban_sdk::Val, + arg_1: soroban_sdk::Val, +) -> soroban_sdk::Val { + soroban_sdk::IntoValForContractFn::into_val_for_contract_fn( + ::run( + <_ as soroban_sdk::unwrap::UnwrapOptimized>::unwrap_optimized( + <_ as soroban_sdk::TryFromValForContractFn< + soroban_sdk::Env, + soroban_sdk::Val, + >>::try_from_val_for_contract_fn(&env, &arg_0), + ), + <_ as soroban_sdk::unwrap::UnwrapOptimized>::unwrap_optimized( + <_ as soroban_sdk::TryFromValForContractFn< + soroban_sdk::Env, + soroban_sdk::Val, + >>::try_from_val_for_contract_fn(&env, &arg_1), + ), + ), + &env, + ) +} +#[doc(hidden)] +#[allow(non_snake_case)] +#[deprecated(note = "use `ContractClient::new(&env, &contract_id).run` instead")] +#[export_name = "run"] +pub extern "C" fn __Contract__run__invoke_raw_extern( + arg_0: soroban_sdk::Val, + arg_1: soroban_sdk::Val, +) -> soroban_sdk::Val { + #[allow(deprecated)] + __Contract__run__invoke_raw(soroban_sdk::Env::default(), arg_0, arg_1) +} diff --git a/tests/fuzz_afl/Cargo.toml b/tests/fuzz_afl/Cargo.toml new file mode 100644 index 000000000..5c2945b7b --- /dev/null +++ b/tests/fuzz_afl/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "test_fuzz_afl" +version.workspace = true +authors = ["Stellar Development Foundation "] +license = "Apache-2.0" +edition = "2021" +publish = false +rust-version.workspace = true + +[lib] +# Adding rlib is required so that the test_fuzz_afl crate can be imported as a +# library into the fuzzing crate. However, having multiple crate types is a +# problem, it'll cause LTO optimizations to be disabled for the cdylib build. +# TODO: Figure out what to do about this. +crate-type = ["cdylib", "rlib"] +doctest = false + +[dependencies] +soroban-sdk = {path = "../../soroban-sdk"} + +[dev-dependencies] +soroban-sdk = {path = "../../soroban-sdk", features = ["testutils"]} diff --git a/tests/fuzz_afl/README.md b/tests/fuzz_afl/README.md new file mode 100644 index 000000000..32da528a3 --- /dev/null +++ b/tests/fuzz_afl/README.md @@ -0,0 +1,255 @@ +# test_fuzz_afl + +A minimal Soroban contract, plus a fuzz test for it that runs under +[AFL++] via the [afl.rs] crate (the `cargo-afl` tooling). + +If you have never fuzzed anything before, this README is for you: follow it +top to bottom and you will watch a fuzzer find a bug in the contract in +`src/lib.rs`. + +[AFL++]: https://aflplus.plus +[afl.rs]: https://github.com/rust-fuzz/afl.rs + +## What is fuzzing? + +A fuzzer runs your code over and over with generated input, watching which +branches the code takes, and mutating the input it feeds in so as to reach +branches it has not reached yet. When an input makes the code panic, the +fuzzer saves that input to disk so you can replay it. It is a way of finding +the inputs you did not think to write a test for. + +Two fuzzers are commonly used with Rust, and the SDK supports both: + +- **libFuzzer**, driven by `cargo-fuzz`. See the `tests/fuzz` crate next + door for the same example written for it. It requires a nightly compiler. +- **AFL++**, driven by `cargo-afl`, which is what this crate uses. It works + on stable Rust, runs each input in a separate forked process (so a crash + cannot corrupt the fuzzer itself), and has a terminal UI that shows what + the campaign is doing. + +Neither is better; they explore differently, and fuzzing a contract with +both finds more than fuzzing it with either. + +## What is in this crate + +``` +tests/fuzz_afl +├── Cargo.toml the contract crate, a normal member of the workspace +├── src/lib.rs the contract being fuzzed +└── fuzz the fuzz crate: a separate crate, and its own workspace + ├── Cargo.toml + ├── in/seed the starting input the fuzzer mutates ("seed corpus") + └── src/fuzz_target_1.rs the fuzz test itself +``` + +The contract has one function that panics on some inputs and not others: + +```rust +pub fn run(a: U256, b: U256) { + if a < b { + panic!("unexpected") + } +} +``` + +The fuzz test in `fuzz/src/fuzz_target_1.rs` turns the fuzzer's raw bytes +into two `U256` values and calls `run` with them. The panic is the bug we +expect the fuzzer to find. + +The fuzz crate is deliberately *not* part of the repository's Cargo +workspace — it declares its own `[workspace]` — because it has to be built +with special compiler flags that you do not want applied to everything else. + +## One-time setup + +You need a Rust toolchain ([rustup]), and on Linux the packages `build-essential`, +`clang`, and `llvm` (on macOS, Xcode command line tools and `brew install llvm`). +`cargo-afl` compiles AFL++ from source when you install it, which is why a C +compiler is needed: + +```console +cargo install cargo-afl --locked +``` + +Then let AFL++ tune the machine for fuzzing. This asks for your password, +because it writes kernel settings that stop crash handlers from stealing +crashes from the fuzzer: + +```console +cargo afl system-config +``` + +If you would rather not run that, AFL++ will tell you at startup exactly +which setting it is unhappy about. + +[rustup]: https://rustup.rs + +## Build the fuzz test + +All the commands below are run from the `fuzz` directory: + +```console +cd tests/fuzz_afl/fuzz +``` + +Build with `cargo afl build` rather than `cargo build`. It is a normal +`cargo build` with the AFL++ instrumentation added, which is how the fuzzer +sees which branches an input reached: + +```console +cargo afl build +``` + +That produces an instrumented binary at `target/debug/fuzz_target_1`. + +Note that this is a debug build. Fuzz in debug, at least at first: debug +builds keep integer overflow checks and `debug_assert!`s turned on, and +those catch bugs that a release build would silently allow. If you later +want the extra speed, `cargo afl build --release` works too, and the binary +lands in `target/release/fuzz_target_1`. + +## Run the fuzzer + +```console +cargo afl fuzz -i in -o out target/debug/fuzz_target_1 +``` + +- `-i in` is the directory of starting inputs. Mutating an existing input is + much more effective than starting from nothing, so a fuzzer always wants + at least one seed. Ours is a file of 64 zero characters, which decodes to + `a == b` and so does not panic. +- `-o out` is where AFL++ writes everything it learns: the inputs it decides + are interesting, the crashes it finds, and its statistics. It is + `.gitignore`d. + +A full-screen status display takes over the terminal. The two numbers to +watch when you are starting out: + +- **exec speed** — how many inputs per second are being tried. Expect + hundreds to a few thousand per second here; a Soroban `Env` is not cheap + to create. +- **saved crashes** — how many distinct crashing inputs have been found. For + this contract it should tick up from 0 within seconds. + +Press `Ctrl-C` to stop. You can resume a campaign later by pointing at the +same output directory with `-i-` instead of `-i in`. + +Two messages that look alarming but are normal here. AFL++ warns about +"instability detected during calibration", and reports a **stability** figure +below 100%: it means the same input did not take exactly the same path +through the code twice. The Soroban host keeps some state for the lifetime of +the process, and AFL++ reuses one process for many inputs, so a little +instability is expected. It makes the fuzzer somewhat less efficient at +finding new paths; it does not make the crashes it finds any less real. The +other is the low **coverage** percentage: it is measured over all the code +linked into the binary, most of which is the host environment, so a few +percent is normal. + +## Reproduce a crash + +Crashing inputs are saved as files in `out/default/crashes/`: + +```console +ls out/default/crashes +``` + +Ignore the `README.txt` there; the `id:000000,...` files are the inputs. The +fuzz binary reads an input on stdin when it is not being driven by AFL++, so +you can replay one directly and see the panic and its backtrace: + +```console +RUST_BACKTRACE=1 ./target/debug/fuzz_target_1 < out/default/crashes/id:000000* +``` + +For this contract the output looks like this: + +``` +thread 'main' panicked at .../soroban-env-host-28.0.2/src/host.rs:892:9: +HostError: Error(WasmVm, InvalidAction) + +Event log (newest first): + 0: [Diagnostic Event] topics:[error, Error(WasmVm, InvalidAction)], data:"escalating error to panic" + 1: [Diagnostic Event] topics:[error, Error(WasmVm, InvalidAction)], data:["contract call failed", run, [2179615797408...8145 76, 2179615797408...227248]] + ... +``` + +Note that the panic you see is the host escalating the contract's failure, +not the contract's own `panic!("unexpected")`; a contract panic is opaque to +the caller, so the message is a generic `HostError`. What tells you what +happened is the event log: it names the function that failed, `run`, and the +arguments it was called with — and there you can see that the fuzzer found a +pair where `a < b`. The crash file is a reproducer you can keep, and the +backtrace below the event log points at the line of the fuzz test that made +the call. + +Two more commands are worth knowing once you have a crash: + +- `cargo afl tmin -i -o target/debug/fuzz_target_1` + shrinks one crashing input down to the smallest input that still crashes, + which usually makes it much easier to understand. +- `cargo afl cmin -i out/default/queue -o corpus target/debug/fuzz_target_1` + reduces the directory of interesting inputs down to a small set with the + same coverage, a good corpus to keep and reuse as `-i` next time. + +## Writing a fuzz test for your own contract + +The fuzzer only knows how to produce bytes, and Soroban types such as +`U256`, `Vec`, `Map`, or `Address` cannot be built from bytes alone — they +live inside an `Env`. The SDK bridges that gap with the +[`SorobanArbitrary`] trait: every contract type has an associated +`Prototype` type that *can* be built from bytes, and that converts into the +real type with `into_val(&env)`. So a fuzz test is: + +1. Declare a struct of prototypes and derive `Arbitrary` on it, which is + what lets the fuzzer generate it: + + ```rust + #[derive(Arbitrary, Debug)] + struct Input { + a: ::Prototype, + b: ::Prototype, + } + ``` + +2. Pass it to `fuzz!`, convert the prototypes into contract types, register + the contract, and call it: + + ```rust + fn main() { + fuzz!(|input: Input| { + let env = Env::default(); + let a: U256 = input.a.into_val(&env); + let b: U256 = input.b.into_val(&env); + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + let _ = client.run(&a, &b); + }); + } + ``` + +Things to keep in mind: + +- Create the `Env` **inside** `fuzz!`, not outside. AFL++ reuses the process + for many inputs ("persistent mode"), so state created outside the closure + would leak from one input into the next and make crashes hard to + reproduce. +- Contract types of your own, those with `#[contracttype]`, get a + `Prototype` automatically, but only when the `soroban-sdk/testutils` + feature is on. That means your contract crate needs its own `testutils` + feature that enables `soroban-sdk/testutils`, and the fuzz crate must turn + it on. +- A panicking contract call is what the fuzzer detects, so call `client.foo()` + and let it panic. If you use `client.try_foo()` you get a `Result` back and + a failing call is no longer a crash — useful when you want to check + something about the error instead, with `assert!`/`panic!` of your own. +- The fuzz crate depends on `arbitrary` directly, because that is where the + `Arbitrary` derive comes from and `fuzz!` expects it to be nameable. Keep + its version matching the one `soroban-sdk` uses. + +[`SorobanArbitrary`]: https://docs.rs/soroban-sdk/latest/soroban_sdk/testutils/arbitrary/trait.SorobanArbitrary.html + +## Note for maintainers of this repository + +`make build-fuzz-afl` builds this fuzz test, and requires `cargo-afl` to be +installed as above. Unlike the `cargo-fuzz` example in `tests/fuzz`, it is +not built in CI, because building it means building AFL++ from source. diff --git a/tests/fuzz_afl/fuzz/.gitignore b/tests/fuzz_afl/fuzz/.gitignore new file mode 100644 index 000000000..5381c0af5 --- /dev/null +++ b/tests/fuzz_afl/fuzz/.gitignore @@ -0,0 +1,3 @@ +target +out +Cargo.lock diff --git a/tests/fuzz_afl/fuzz/Cargo.toml b/tests/fuzz_afl/fuzz/Cargo.toml new file mode 100644 index 000000000..7aa8e4dba --- /dev/null +++ b/tests/fuzz_afl/fuzz/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "test_fuzz_afl-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[dependencies] +afl = "0.18" +arbitrary = { version = "~1.3.0", features = ["derive"] } +soroban-sdk = { path = "../../../soroban-sdk", features = [ "testutils" ]} +test_fuzz_afl = { path = ".." } + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[profile.release] +debug = 1 + +[[bin]] +name = "fuzz_target_1" +path = "src/fuzz_target_1.rs" +test = false +doc = false diff --git a/tests/fuzz_afl/fuzz/in/seed b/tests/fuzz_afl/fuzz/in/seed new file mode 100644 index 000000000..9c7bc66b5 --- /dev/null +++ b/tests/fuzz_afl/fuzz/in/seed @@ -0,0 +1 @@ +0000000000000000000000000000000000000000000000000000000000000000 \ No newline at end of file diff --git a/tests/fuzz_afl/fuzz/src/fuzz_target_1.rs b/tests/fuzz_afl/fuzz/src/fuzz_target_1.rs new file mode 100644 index 000000000..10272d74b --- /dev/null +++ b/tests/fuzz_afl/fuzz/src/fuzz_target_1.rs @@ -0,0 +1,29 @@ +use afl::fuzz; +use arbitrary::Arbitrary; + +use soroban_sdk::{testutils::arbitrary::SorobanArbitrary, Env, IntoVal, U256}; + +use test_fuzz_afl::{Contract, ContractClient}; + +#[derive(Arbitrary, Debug)] +struct Input { + a: ::Prototype, + b: ::Prototype, +} + +fn main() { + // Every iteration gets a fresh `Env` so that no ledger state leaks from one + // input to the next. AFL++ runs the body of `fuzz!` in a persistent loop, + // so anything created outside of it would be shared between inputs. + fuzz!(|input: Input| { + let env = Env::default(); + + let a: U256 = input.a.into_val(&env); + let b: U256 = input.b.into_val(&env); + + let contract_id = env.register(Contract, ()); + let client = ContractClient::new(&env, &contract_id); + + let _ = client.run(&a, &b); + }); +} diff --git a/tests/fuzz_afl/src/lib.rs b/tests/fuzz_afl/src/lib.rs new file mode 100644 index 000000000..f57d35015 --- /dev/null +++ b/tests/fuzz_afl/src/lib.rs @@ -0,0 +1,14 @@ +#![no_std] +use soroban_sdk::{contract, contractimpl, U256}; + +#[contract] +pub struct Contract; + +#[contractimpl] +impl Contract { + pub fn run(a: U256, b: U256) { + if a < b { + panic!("unexpected") + } + } +}