Pruebas de Fuzz
El ejemplo de fuzzing demuestra cómo probar contratos Soroban con cargo-fuzz
y personalizar la entrada para las pruebas de fuzz con crate arbitrary
. También demuestra cómo adaptar las pruebas de fuzz en pruebas de propiedad reutilizables con los crates proptest
y proptest-arbitrary-interop
. Se basa en el ejemplo de timelock.
Ejecutar el Ejemplo
Primero sigue el proceso de configuración para preparar tu entorno de desarrollo, luego clona la etiqueta v23.0.0
del repositorio soroban-examples
:
git clone -b v23.0.0 https://github.com/stellar/soroban-examples
También necesitarás la herramienta cargo-fuzz
, y para ejecutar cargo-fuzz
necesitarás un toolchain Rust nightly:
cargo install cargo-fuzz
rustup install nightly
Para ejecutar una de las pruebas de fuzz, navega al directorio fuzzing
y ejecuta el subcomando cargo fuzz
con el toolchain nightly
:
cd fuzzing
cargo +nightly fuzz run fuzz_target_1
Si estás desarrollando en MacOS, puede que necesites añadir el flag --sanitizer=thread
para corregir algunos errores de enlace conocidos.
Deberías ver una salida que comienza así:
$ cargo +nightly fuzz run fuzz_target_1
Compiling soroban-fuzzing-contract v0.0.0 (/home/azureuser/data/stellar/soroban-examples/fuzzing)
Compiling soroban-fuzzing-contract-fuzzer v0.0.0 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz)
Finished release [optimized + debuginfo] target(s) in 23.74s
Finished release [optimized + debuginfo] target(s) in 0.07s
Running `fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1 ...`
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 886588732
INFO: Loaded 1 modules (1093478 inline 8-bit counters): 1093478 [0x55eb8e2c7620, 0x55eb8e3d2586),
INFO: Loaded 1 PC tables (1093478 PCs): 1093478 [0x55eb8e3d2588,0x55eb8f481be8),
INFO: 105 files found in /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/corpus/fuzz_target_1
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: seed corpus: files: 105 min: 32b max: 61b total: 3558b rss: 86Mb
#2 pulse ft: 8355 exec/s: 1 rss: 307Mb
#4 pulse cov: 8354 ft: 11014 corp: 1/32b exec/s: 2 rss: 313Mb
#8 pulse cov: 8495 ft: 12420 corp: 4/128b exec/s: 4 rss: 315Mb
El resto de este tutorial explicará cómo configurar esta prueba de fuzz, interpretar esta salida y resolver fallos de fuzzing.
Contexto: Pruebas de Fuzz y Rust
El fuzzing es un tipo de prueba donde se alimentan repetidamente nuevos inputs en un programa con la esperanza de encontrar errores inesperados. Este estilo de prueba se emplea comúnmente para aumentar la confianza en la corrección del software sensible a la seguridad.
En Rust, el fuzzing se realiza más a menudo con la herramienta cargo-fuzz
, que utiliza libfuzzer
de LLVM, aunque hay otras herramientas de fuzzing disponibles.
Soroban tiene soporte integrado para pruebas de fuzz en contratos Soroban con cargo-fuzz
.
cargo-fuzz
es un fuzzer basado en mutaciones: ejecuta un programa de prueba, pasándole input generado; mientras el programa se ejecuta, el fuzzer monitorea qué ramas toma el programa y qué funciones ejecuta; tras la ejecución, el fuzzer utiliza esta información para tomar decisiones sobre cómo mutar el input previamente usado para crear nuevo input que podría descubrir más ramas y funciones; luego ejecuta la prueba nuevamente con el nuevo input, repitiendo este proceso durante potencialmente millones de iteraciones. De esta manera, cargo-fuzz
puede explorar automáticamente los caminos de ejecución a través del programa que pueden nunca ser vistos por otros tipos de pruebas.
Si una prueba de fuzz genera un panic o se cierra de forma abrupta, cargo-fuzz
lo considera una falla y proporciona instrucciones para repetir la prueba con los inputs fallidos.
Las pruebas de fuzz generalmente son un proceso exploratorio e interactivo, donde el programador desarrolla esquemas para producir inputs que estresen el programa de maneras interesantes, observan el comportamiento de la prueba de fuzz y hacen iteraciones sobre la propia prueba.
Resolver una falla en las pruebas de fuzz generalmente implica capturar el input problemático en una prueba unitaria. La prueba de fuzz puede o no ser mantenida, dependiendo de las determinaciones sobre el costo de mantener el fuzzer frente a la probabilidad de que continúe encontrando errores en el futuro.
Mientras que el fuzzing de software no seguro en memoria tiende a ser más lucrativo que el fuzzing de software en Rust, sigue siendo relativamente común encontrar panics y otros errores lógicos en Rust a través del fuzzing.
En Rust, múltiples fuzzers son mantenidos por la organización de GitHub rust-fuzz
, que también mantiene una "vitrina de trofeos" de errores de Rust encontrados a través del fuzzing.
Acerca del Ejemplo
El ejemplo utilizado para este tutorial está basado en el programa de ejemplo timelock
, con algunos cambios para demostrar el fuzzing.
El contrato, ClaimableBalanceContract
, permite que una parte deposite una cantidad arbitraria de un token en el contrato, especificando además: los claimants
, direcciones que pueden retirar del contrato; y el time_bound
, una especificación de cuándo esos reclamantes pueden retirar de la cuenta.
El tipo TimeBound
luce así
#[derive(Clone, Debug)]
#[contracttype]
pub enum TimeBoundKind {
Before,
After,
}
#[derive(Clone, Debug)]
#[contracttype]
pub struct TimeBound {
pub kind: TimeBoundKind,
pub timestamp: u64,
}
ClaimableBalanceContract
tiene dos métodos, deposit
y claim
:
pub fn deposit(
env: Env,
from: Address,
token: Address,
amount: i128,
claimants: Vec<Address>,
time_bound: TimeBound,
);
pub fn claim(
env: Env,
claimant: Address,
amount: i128,
);
deposit
se puede llamar exitosamente solo una vez, después de lo cual se puede llamar a claim
múltiples veces hasta que el balance esté completamente agotado, momento en el cual el contrato se vuelve inactivo y ya no puede ser utilizado.
Configuración de Pruebas de Fuzz
Para estos ejemplos, las pruebas de fuzz han sido creadas para ti, pero normalmente usarías el comando cargo fuzz init
para crear un proyecto de fuzzing como un subdirectorio del contrato bajo prueba. Para hacer esto, navega al directorio del contrato, en este caso, soroban-examples/fuzzing
, y ejecuta
cargo fuzz init
Un proyecto cargo-fuzz
es su propia crate, que vive en el subdirectorio fuzz
de la crate que se está probando. Esta crate tiene su propio Cargo.toml
y Cargo.lock
, y otro subdirectorio, fuzz_targets
, que contiene programas Rust, cada uno con su propia prueba de fuzzing.
Nuestro directorio soroban-examples/fuzzing
se ve así
Cargo.toml
- este es el manifiesto del contratoCargo.lock
src
lib.rs
- este es el código del contrato
fuzz
- esta es la crate de fuzzingCargo.toml
- este es el manifiesto de la crate de fuzzingCargo.lock
fuzz_targets
fuzz_target_1.rs
- esta es una prueba de fuzzing individualfuzz_target_2.rs
Hay consideraciones especiales a tener en cuenta en la configuración tanto del manifiesto del contrato como del manifiesto de la crate de fuzzing.
Dentro del manifiesto del contrato hay que especificar el tipo de crate como "cdylib" y "rlib":
[package]
name = "soroban-fuzzing-contract"
version = "0.0.0"
edition = "2021"
publish = false
rust-version = "1.89.0"
[lib]
crate-type = ["cdylib", "rlib"]
doctest = false
[features]
testutils = []
En la mayoría de ejemplos, un contrato Soroban será solo un "cdylib", una crate Rust compilada como un módulo Wasm cargable dinámicamente. Sin embargo, para el fuzzing, la crate de fuzzing debe poder enlazarse con la crate del contrato como una librería Rust, una "rlib".
Ten en cuenta que cargo tiene una característica/error que inhibe LTO de cdylibs cuando una crate es tanto "cdylib" como "rlib". Esto se puede solucionar compilando el contrato con soroban contract build
o cargo rustc --crate-type cdylib
en lugar del típico cargo build
.
La crate del contrato también debe habilitar la característica "testutils". Cuando "testutils" está activado, el macro contracttype
del SDK Soroban emite código adicional necesario para ejecutar pruebas de fuzzing.
Dentro del manifiesto de la crate de fuzzing se deben activar las características "testutils" tanto en la crate del contrato como en la crate soroban-sdk
:
[package]
name = "soroban-fuzzing-contract-fuzzer"
version = "0.0.0"
publish = false
edition = "2021"
rust-version = "1.79.0"
[package.metadata]
cargo-fuzz = true
[dependencies]
libfuzzer-sys = "0.4"
soroban-sdk = { version = "23.0.1", features = ["testutils"] }
soroban-env-host = { version = "23.0.1" }
soroban-ledger-snapshot = { version = "23.0.1" }
[dependencies.soroban-fuzzing-contract]
path = ".."
features = ["testutils"]
Una prueba de fuzzing simple
Primero veamos fuzz_target_1.rs
. Esta prueba de fuzzing hace dos cosas: primero deposita una cantidad arbitraria y luego reclama una cantidad arbitraria.
Puedes ejecutar este fuzzer desde el directorio soroban-examples/fuzzing
con el siguiente comando:
cargo +nightly fuzz run fuzz_target_1
El punto de entrada y el código de configuración para pruebas de fuzzing de contratos Soroban típicamente se ven así:
#[derive(Arbitrary, Debug)]
struct Input {
deposit_amount: i128,
claim_amount: i128,
}
fuzz_target!(|input: Input| {
let env = Env::default();
env.mock_all_auths();
env.ledger().set(LedgerInfo {
timestamp: 12345,
protocol_version: 1,
sequence_number: 10,
network_id: Default::default(),
base_reserve: 10,
min_temp_entry_ttl: u32::MAX,
min_persistent_entry_ttl: u32::MAX,
max_entry_ttl: u32::MAX,
});
// Turn off the CPU/memory budget for testing.
env.cost_estimate().budget().reset_unlimited();
// ... do fuzzing here ...
}
En lugar de una función main
, cargo-fuzz
usa un punto de entrada especial definido por el macro fuzz_target!
Este macro acepta un closure Rust que recibe input
, cualquier tipo Rust que implemente el trait Arbitrary
. Aquí hemos definido una estructura, Input
, que deriva de Arbitrary
.
cargo-fuzz
será responsable de generar input
y llamar repetidamente a este closure.
Para probar un contrato Soroban, debemos configurar un Env
. Ten en cuenta que hemos deshabilitado el presupuesto de CPU y memoria: esto nos permitirá fuzzear rutas de código arbitrariamente complejas sin preocuparnos por quedarnos sin presupuesto; podemos asumir que quedarse sin presupuesto durante una transacción siempre falla correctamente, cancelando la transacción; no es algo que necesitemos fuzzear.
Consulta el código fuente de fuzz_target_1.rs
para configuración adicional de este contrato.
Este fuzzer realiza dos pasos: depositar y luego reclamar:
// Deposit, then assert invariants.
{
let _ = timelock_client.try_deposit(
&depositor_address,
&token_contract_id,
&input.deposit_amount,
&vec![&env, claimant_address.clone()],
&TimeBound {
kind: TimeBoundKind::Before,
timestamp: 123456,
},
);
assert_invariants(&env, &timelock_contract_id, &token_client, &input);
}
// Claim, then assert invariants.
{
let _ = timelock_client.try_claim(&claimant_address, &input.claim_amount);
assert_invariants(&env, &timelock_contract_id, &token_client, &input);
}
Hay varias estrategias potenciales para escribir pruebas de fuzzing. La estrategia en esta prueba es hacer llamadas arbitrarias, posiblemente extrañas e irreales, al contrato, sin importar si esas llamadas tienen éxito o fallan, y luego hacer afirmaciones sobre el estado del contrato.
Como hay muchos casos potenciales de fallo para cualquier llamada al contrato, no queremos escribir una prueba de fuzzing intentando interpretar el éxito o fracaso de una llamada dada: ese camino conduce a duplicar la lógica del contrato dentro de la prueba de fuzzing. En lugar de eso, solo queremos asegurar que, sin importar lo que pasó durante la ejecución, el contrato nunca quede en un estado inválido.
Nota el uso de la función cliente try_
para invocar el contrato. Cada función del contrato puede invocarse con una variante try_
, que captura cualquier error, incluyendo panics y caídas, y devuelve el valor en caso de éxito o un error en caso contrario. Sin usar la variante try_
, un panic dentro de un contrato causará que la prueba de fuzzing falle inmediatamente, pero en la mayoría de los casos un panic dentro de un contrato no indica un bug, es simplemente cómo un contrato Soroban cancela una transacción. try_
devuelve un Result
, pero aquí lo descartamos.
Finalmente, la función assert_invariants
es donde hacemos las afirmaciones que podamos sobre el estado del contrato:
/// Directly inspect the contract state and make assertions about it.
fn assert_invariants(
env: &Env,
timelock_contract_id: &Address,
token_client: &TokenClient,
input: &Input,
) {
// Configure the environment to access the timelock contract's storage.
env.as_contract(timelock_contract_id, || {
let storage = env.storage().persistent();
// Get the two datums owned by the timelock contract.
let is_initialized = storage.has(&DataKey::Init);
let claimable_balance = storage.get::<_, ClaimableBalance>(&DataKey::Balance);
// Call the token client to get the balance held in the timelock contract.
// This consumes contract execution budget.
let actual_token_balance = token_client.balance(timelock_contract_id);
// There can only be a claimaible balance after the contract is initialized,
// but once the balance is claimed there is no balance,
// but the contract remains initialized.
// This is a truth table of valid states.
assert!(match (is_initialized, claimable_balance.is_some()) {
(false, false) => true,
(false, true) => false,
(true, true) => true,
(true, false) => true,
});
assert!(actual_token_balance >= 0);
if let Some(claimable_balance) = claimable_balance {
assert!(claimable_balance.amount > 0);
assert!(claimable_balance.amount <= input.deposit_amount);
assert_eq!(claimable_balance.amount, actual_token_balance);
assert!(claimable_balance.claimants.len() > 0);
}
});
}
Interpretar la salida de cargo-fuzz
Si ejecutas cargo-fuzz
con fuzz_target_1
, dentro del directorio soroban-examples/fuzzing
, verás una salida similar a:
$ cargo +nightly fuzz run fuzz_target_1
Compiling soroban-fuzzing-contract v0.0.0 (/home/azureuser/data/stellar/soroban-examples/fuzzing)
Compiling soroban-fuzzing-contract-fuzzer v0.0.0 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz)
Finished release [optimized + debuginfo] target(s) in 25.18s
Finished release [optimized + debuginfo] target(s) in 0.08s
Running `fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1 -artifact_prefix=/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/artifacts/fuzz_target_1/ /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/corpus/fuzz_target_1`
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1384064486
INFO: Loaded 1 modules (1122058 inline 8-bit counters): 1122058 [0x561f6ecd4fc0, 0x561f6ede6eca),
INFO: Loaded 1 PC tables (1122058 PCs): 1122058 [0x561f6ede6ed0,0x561f6ff05f70),
INFO: 173 files found in /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/corpus/fuzz_target_1
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: seed corpus: files: 173 min: 32b max: 61b total: 6039b rss: 83Mb
#4 pulse cov: 4848 ft: 10214 corp: 1/32b exec/s: 2 rss: 313Mb
#8 pulse cov: 8507 ft: 11743 corp: 4/128b exec/s: 4 rss: 315Mb
#16 pulse cov: 8512 ft: 12393 corp: 10/320b exec/s: 8 rss: 319Mb
thread '<unnamed>' panicked at 'assertion failed: claimable_balance.amount > 0', fuzz_targets/fuzz_target_1.rs:130:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
==6102== ERROR: libFuzzer: deadly signal
#0 0x561f6ae3a431 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x1c80431) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
#1 0x561f6e3855b0 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x51cb5b0) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
#2 0x561f6e35c08a (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x51a208a) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
#3 0x7fce05f5e08f (/lib/x86_64-linux-gnu/libc.so.6+0x4308f) (BuildId: 1878e6b475720c7c51969e69ab2d276fae6d1dee)
#4 0x7fce05f5e00a (/lib/x86_64-linux-gnu/libc.so.6+0x4300a) (BuildId: 1878e6b475720c7c51969e69ab2d276fae6d1dee)
#5 0x7fce05f3d858 (/lib/x86_64-linux-gnu/libc.so.6+0x22858) (BuildId: 1878e6b475720c7c51969e69ab2d276fae6d1dee)
...
#27 0x561f6e3847b9 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x51ca7b9) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
#28 0x561f6ad98346 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x1bde346) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
#29 0x7fce05f3f082 (/lib/x86_64-linux-gnu/libc.so.6+0x24082) (BuildId: 1878e6b475720c7c51969e69ab2d276fae6d1dee)
#30 0x561f6ad9837d (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x1bde37d) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
NOTE: libFuzzer has rudimentary signal handlers.
Combine libFuzzer with AddressSanitizer or similar for better crash reports.
SUMMARY: libFuzzer: deadly signal
MS: 0 ; base unit: 0000000000000000000000000000000000000000
0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x5d,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0x0,0xff,0x5f,0x5f,0x52,0xff,
\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000]\000\000\000\000\000\000\000\000\377__R\377
artifact_prefix='/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/artifacts/fuzz_target_1/'; Test unit written to /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
Base64: AAAAAAAAAAAAAAAAAAAAAAAAXQAAAAAAAAAA/19fUv8=
────────────────────────────────────────────────────────────────────────────────
Failing input:
fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
Output of `std::fmt::Debug`:
Input {
deposit_amount: 0,
claim_amount: -901525218878596739118967460911579136,
}
Reproduce with:
cargo fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
Minimize test case with:
cargo fuzz tmin fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
────────────────────────────────────────────────────────────────────────────────
Error: Fuzz target exited with exit status: 77
Esto es una falla de fuzzing, indicando un bug en el propio fuzzer o en el programa. Los detalles serán diferentes.
Aquí está la misma salida, con las líneas menos importantes recortadas:
thread '<unnamed>' panicked at 'assertion failed: claimable_balance.amount > 0', fuzz_targets/fuzz_target_1.rs:130:13
...
Failing input:
fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
Output of `std::fmt::Debug`:
Input {
deposit_amount: 0,
claim_amount: -901525218878596739118967460911579136,
}
Reproduce with:
cargo fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
Minimize test case with:
cargo fuzz tmin fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
La primera línea aquí es impresa por nuestro programa Rust, e indica exactamente dónde el fuzzer hizo panic. Las líneas posteriores indican cómo reproducir este caso fallido.
Lo primero que debes hacer cuando obtienes una falla de fuzzing es copiar el comando para reproducir la falla, para que puedas usarlo para depurar:
cargo +nightly fuzz run fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-04704b1542f61a21a4649e39023ec57ff502f627
Observa que necesitamos decirle a cargo
que use el toolchain nightly con el flag +nightly
, algo que cargo-fuzz
no imprime en su versión del comando.
Otra cosa a notar es que por defecto, cargo-fuzz
/ libfuzzer
no imprime los nombres de funciones en su salida, como en el stack trace:
==6102== ERROR: libFuzzer: deadly signal
#0 0x561f6ae3a431 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x1c80431) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
...
#28 0x561f6ad98346 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x1bde346) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
#29 0x7fce05f3f082 (/lib/x86_64-linux-gnu/libc.so.6+0x24082) (BuildId: 1878e6b475720c7c51969e69ab2d276fae6d1dee)
#30 0x561f6ad9837d (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x1bde37d) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
Dependiendo de cómo esté configurado tu sistema, puede que tengas o no este problema. Para imprimir los stack traces, libfuzzer
necesita el programa llvm-symbolizer
. En sistemas basados en Ubuntu esto se puede instalar con el paquete llvm-dev
:
sudo apt install llvm-dev
Después de esto, libfuzzer
imprimirá nombres de funciones desmanglados en lugar de direcciones:
==6323== ERROR: libFuzzer: deadly signal
#0 0x557c9da6a431 in __sanitizer_print_stack_trace /rustc/llvm/src/llvm-project/compiler-rt/lib/asan/asan_stack.cpp:87:3
#1 0x557ca0fb55b0 in fuzzer::PrintStackTrace() /home/azureuser/.cargo/registry/src/index.crates.io-6f17d22bba15001f/libfuzzer-sys-0.4.5/libfuzzer/FuzzerUtil.cpp:210:38
#2 0x557ca0f8c08a in fuzzer::Fuzzer::CrashCallback() /home/azureuser/.cargo/registry/src/index.crates.io-6f17d22bba15001f/libfuzzer-sys-0.4.5/libfuzzer/FuzzerLoop.cpp:233:18
#3 0x557ca0f8c08a in fuzzer::Fuzzer::CrashCallback() /home/azureuser/.cargo/registry/src/index.crates.io-6f17d22bba15001f/libfuzzer-sys-0.4.5/libfuzzer/FuzzerLoop.cpp:228:6
#4 0x7ff19e84d08f (/lib/x86_64-linux-gnu/libc.so.6+0x4308f) (BuildId: 1878e6b475720c7c51969e69ab2d276fae6d1dee)
#5 0x7ff19e84d00a in __libc_signal_restore_set /build/glibc-SzIz7B/glibc-2.31/signal/../sysdeps/unix/sysv/linux/internal-signals.h:86:3
#6 0x7ff19e84d00a in raise /build/glibc-SzIz7B/glibc-2.31/signal/../sysdeps/unix/sysv/linux/raise.c:48:3
#7 0x7ff19e82c858 in abort /build/glibc-SzIz7B/glibc-2.31/stdlib/abort.c:79:7
...
#23 0x557c9daee89a in fuzz_target_1::assert_invariants::hd6d4f9549b01c31c /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/fuzz_targets/fuzz_target_1.rs:103:5
#24 0x557c9daee89a in fuzz_target_1::_::run::hac1117cb3dfecb2b /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/fuzz_targets/fuzz_target_1.rs:69:9
#25 0x557c9daecea6 in rust_fuzzer_test_input /home/azureuser/.cargo/registry/src/index.crates.io-6f17d22bba15001f/libfuzzer-sys-0.4.5/src/lib.rs:297:60
...
#37 0x557c9d9c8346 in main /home/azureuser/.cargo/registry/src/index.crates.io-6f17d22bba15001f/libfuzzer-sys-0.4.5/libfuzzer/FuzzerMain.cpp:20:30
#38 0x7ff19e82e082 in __libc_start_main /build/glibc-SzIz7B/glibc-2.31/csu/../csu/libc-start.c:308:16
#39 0x557c9d9c837d in _start (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1+0x1bde37d) (BuildId: 6a95a932984a405ebab8171dddc9f812fdf16846)
Para continuar, nuestro programa tiene un bug que debería ser fácil de corregir inspeccionando el error y haciendo una ligera modificación en el código fuente.
Una vez corregido el bug, el fuzzer se ejecutará continuamente, produciendo una salida que se ve así
$ cargo +nightly fuzz run fuzz_target_1
Compiling soroban-fuzzing-contract v0.0.0 (/home/azureuser/data/stellar/soroban-examples/fuzzing)
Compiling soroban-fuzzing-contract-fuzzer v0.0.0 (/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz)
Finished release [optimized + debuginfo] target(s) in 24.91s
Finished release [optimized + debuginfo] target(s) in 0.08s
Running `fuzz/target/x86_64-unknown-linux-gnu/release/fuzz_target_1 -artifact_prefix=/home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/artifacts/fuzz_target_1/ /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/corpus/fuzz_target_1`
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 1619748028
INFO: Loaded 1 modules (1122061 inline 8-bit counters): 1122061 [0x5647a55b9080, 0x5647a56caf8d),
INFO: Loaded 1 PC tables (1122061 PCs): 1122061 [0x5647a56caf90,0x5647a67ea060),
INFO: 173 files found in /home/azureuser/data/stellar/soroban-examples/fuzzing/fuzz/corpus/fuzz_target_1
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
INFO: seed corpus: files: 173 min: 32b max: 61b total: 6039b rss: 85Mb
#2 pulse ft: 8067 exec/s: 1 rss: 312Mb
#4 pulse cov: 8068 ft: 10709 corp: 1/32b exec/s: 2 rss: 315Mb
#8 pulse cov: 8476 ft: 11498 corp: 5/160b exec/s: 4 rss: 317Mb
#16 pulse cov: 8512 ft: 12362 corp: 9/288b exec/s: 8 rss: 320Mb
#32 pulse cov: 8516 ft: 13290 corp: 19/608b exec/s: 10 rss: 326Mb
#64 pulse cov: 8516 ft: 13311 corp: 27/864b exec/s: 21 rss: 340Mb
#128 pulse cov: 8540 ft: 13536 corp: 37/1196b exec/s: 25 rss: 365Mb
#175 INITED cov: 8540 ft: 13580 corp: 42/1387b exec/s: 29 rss: 382Mb
#177 NEW cov: 8545 ft: 13821 corp: 43/1419b lim: 48 exec/s: 29 rss: 384Mb L: 32/48 MS: 1 ChangeASCIIInt-
#178 NEW cov: 8545 ft: 13824 corp: 44/1451b lim: 48 exec/s: 29 rss: 384Mb L: 32/48 MS: 1 ChangeBinInt-
#229 NEW cov: 8545 ft: 13826 corp: 45/1483b lim: 48 exec/s: 38 rss: 401Mb L: 32/48 MS: 1 ChangeByte-
#256 pulse cov: 8545 ft: 13826 corp: 45/1483b lim: 48 exec/s: 36 rss: 410Mb
#361 NEW cov: 8545 ft: 13830 corp: 46/1521b lim: 48 exec/s: 40 rss: 451Mb L: 38/48 MS: 5 ShuffleBytes-CMP-EraseBytes-CopyPart-ChangeBinInt- DE: "\005\000\000\000"-
NEW_FUNC[1/1]: 0x5647a2964640 in rand::rngs::adapter::reseeding::ReseedingCore$LT$R$C$Rsdr$GT$::reseed_and_generate::ha760ded93293681c /home/azureuser/.cargo/registry/src/index.crates.io-6f17d22bba15001f/rand-0.7.3/src/rngs/adapter/reseeding.rs:235
#368 NEW cov: 8557 ft: 13842 corp: 47/1566b lim: 48 exec/s: 40 rss: 454Mb L: 45/48 MS: 2 CrossOver-InsertRepeatedBytes-
#512 pulse cov: 8557 ft: 13842 corp: 47/1566b lim: 48 exec/s: 46 rss: 502Mb
#850 NEW cov: 8557 ft: 13843 corp: 48/1610b lim: 48 exec/s: 53 rss: 591Mb L: 44/48 MS: 2 CopyPart-ChangeBit-
#1024 pulse cov: 8557 ft: 13843 corp: 48/1610b lim: 48 exec/s: 56 rss: 645Mb
#1796 NEW cov: 8557 ft: 13863 corp: 49/1642b lim: 53 exec/s: 71 rss: 669Mb L: 32/48 MS: 1 ChangeBinInt-
#1913 NEW cov: 8557 ft: 13864 corp: 50/1675b lim: 53 exec/s: 73 rss: 669Mb L: 33/48 MS: 2 ShuffleBytes-InsertByte-
#3749 REDUCE cov: 8557 ft: 13864 corp: 50/1670b lim: 68 exec/s: 98 rss: 669Mb L: 39/48 MS: 1 EraseBytes-
...
Y esta salida continuará hasta que el fuzzer sea detenido con Ctrl-C
.
Ahora, veamos una línea única de la salida del fuzzer:
#177 NEW cov: 8545 ft: 13821 corp: 43/1419b lim: 48 exec/s: 29 rss: 384Mb L: 32/48 MS: 1 ChangeASCIIInt-
La columna más importante aquí es cov
. Esta es una medida acumulativa de ramas cubiertas por el fuzzer. Cuando este número deja de aumentar, probablemente el fuzzer ha explorado tanto del programa como puede. Las otras columnas se describen en la documentación de libfuzzer
.
Finalmente, veamos esta advertencia:
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes.
Por defecto, libfuzzer
solo genera entradas de hasta 4096 bytes. En muchos casos, esto puede ser razonable, pero cargo-fuzz
puede aumentar el max_len
añadiendo el argumento después de --
:
cargo +nightly fuzz run fuzz_target_1 -- -max_len=20000
Todas las opciones de libfuzzer pueden listarse con
cargo +nightly fuzz run fuzz_target_1 -- -help=1
Consulta la documentación de libfuzzer
para más información.
Aceptar tipos Soroban como entrada con el trait SorobanArbitrary
Las entradas al macro fuzz_target!
deben implementar el trait Arbitrary
, que acepta bytes del driver de fuzzing y los convierte en valores Rust. Sin embargo, los tipos Soroban son gestionados por el entorno host, y por lo tanto deben crearse a partir de un valor Env
, que no está disponible para el driver de fuzzing. El trait SorobanArbitrary
, implementado para todos los tipos de contrato Soroban, existe para salvar esta diferencia: define un patrón de prototipo por el que el macro fuzz_target
crea valores prototipo que el programa de fuzzing puede convertir en valores de contrato con los traits estándar de conversión de soroban, FromVal
o IntoVal
.
Los tipos de prototipos están identificados por el tipo asociado SorobanArbitrary::Prototype
:
pub trait SorobanArbitrary:
TryFromVal<Env, Self::Prototype> + IntoVal<Env, Val> + TryFromVal<Env, Val>
{
type Prototype: for<'a> Arbitrary<'a>;
}
Los tipos que implementan SorobanArbitrary
incluyen:
i32
,u32
,i64
,u64
,i128
,u128
,I256
,U256
,()
, ybool
,Error
,Bytes
,BytesN
,Vec
,Map
,Address
,Symbol
,Val
,
Todos los tipos de contrato definidos por el usuario, aquellos con el atributo contracttype
, derivan automáticamente SorobanArbitrary
. Ten en cuenta que SorobanArbitrary
solo se deriva cuando la característica Cargo "testutils" está activa. Esto implica que, en general, para hacer un contrato Soroban susceptible de fuzzing, la crate del contrato debe definir una feature Cargo "testutils", esa feature debe activar la feature "soroban-sdk/testutils", y la prueba de fuzzing, que es una crate propia, debe activar esa feature.
Una prueba de fuzzing más compleja
El ejemplo fuzz_target_2.rs
demuestra el uso de SorobanArbitrary
, el avance del tiempo, y técnicas de fuzzing más avanzadas.
Esta prueba de fuzzing toma una entrada mucho más compleja, donde algunos de los valores son tipos definidos por el usuario exportados desde el contrato en prueba. La prueba está estructurada como un intérprete simple, donde el arnés de fuzzing provee "pasos" generados arbitrariamente, donde cada paso es un comando deposit
o un comando claim
. La prueba trata entonces cada uno de estos pasos como una transacción separada: mantiene una imagen del estado del blockchain, y para cada paso crea un ambiente nuevo para ejecutar la llamada al contrato, simulando el avance del tiempo entre pasos. Como en el ejemplo previo, se hacen afirmaciones después de cada paso.
La entrada al fuzzer se ve, en parte, así:
#[derive(Arbitrary, Debug)]
struct Input {
addresses: [<Address as SorobanArbitrary>::Prototype; NUM_ADDRESSES],
#[arbitrary(with = |u: &mut Unstructured| u.int_in_range(0..=i128::MAX))]
token_mint: i128,
steps: RustVec<Step>,
}
#[derive(Arbitrary, Debug)]
struct Step {
#[arbitrary(with = |u: &mut Unstructured| u.int_in_range(1..=u64::MAX))]
advance_time: u64,
command: Command, // `Command` not shown here - see the full source.
}
Esto muestra cómo usar el tipo asociado SorobanArbitrary::Prototype
para definir las entradas al fuzzer. Una Address
Soroban solo puede crearse con un Env
, por lo que no puede generarse directamente con el trait Arbitrary
. En lugar de eso usamos el nombre completamente calificado del prototipo de Address
, <Address as SorobanArbitrary>::Prototype
, para pedir el prototipo de Address
. Cuando el fuzzer necesita la Address
la instanciamos con el trait FromVal
:
let depositor_address = Address::from_val(&env, &input.addresses[cmd.depositor_index]);
El contrato que estamos fuzzing es un contrato timelock, donde el cálculo del tiempo es crucial para la corrección. Así que nuestra prueba debe tener en cuenta el avance del tiempo.
El contrato define un tipo TimeBound
y lo acepta en el método deposit
:
#[derive(Clone, Debug)]
#[contracttype]
pub struct TimeBound {
pub kind: TimeBoundKind,
pub timestamp: u64,
}
#[contractimpl]
impl ClaimableBalanceContract {
pub fn deposit(
env: Env,
from: Address,
token: Address,
amount: i128,
claimants: Vec<Address>,
time_bound: TimeBound,
) {
...
}
}
En nuestro fuzzer, uno de los comandos posibles emitidos en cada paso es un DepositCommand
:
#[derive(Arbitrary, Debug)]
struct DepositCommand {
#[arbitrary(with = |u: &mut Unstructured| u.int_in_range(0..=NUM_ADDRESSES - 1))]
depositor_index: usize,
amount: i128,
// This is an ugly way to get a vector of integers in range
#[arbitrary(with = |u: &mut Unstructured| {
u.arbitrary_len::<usize>().map(|len| {
(0..len).map(|_| {
u.int_in_range(0..=NUM_ADDRESSES - 1)
}).collect::<Result<RustVec<usize>, _>>()
}).and_then(|inner_result| inner_result)
})]
claimant_indexes: RustVec<usize>,
time_bound: <TimeBound as SorobanArbitrary>::Prototype,
}
Nota que este comando nuevamente usa el tipo asociado SorobanArbitrary::Prototype
para aceptar un TimeBound
como entrada.
Para avanzar el tiempo mantenemos una LedgerSnapshot
, definido en la crate soroban-ledger-snapshot
. Para cada paso llamamos a Env::from_snapshot
para crear un ambiente nuevo para ejecutar el paso, luego a Env::to_snapshot
para crear una nueva imagen para usar en el paso siguiente.
Aquí hay un esquema simplificado de cómo funciona esto. Consulta el código fuente completo para más detalles.
let snapshot = {
let init_ledger = LedgerInfo {
timestamp: 12345,
protocol_version: 1,
sequence_number: 10,
network_id: Default::default(),
base_reserve: 10,
min_temp_entry_ttl: u32::MAX,
min_persistent_entry_ttl: u32::MAX,
max_entry_ttl: u32::MAX,
};
LedgerSnapshot::from(init_ledger, None)
};
let mut prev_env = Env::from_snapshot(init_snapshot);
for step in &config.input.steps {
// Advance time and create a new env from snapshot.
let curr_env = {
let mut snapshot = prev_env.to_snapshot();
snapshot.ledger.sequence_number += 1;
snapshot.ledger.timestamp = snapshot.ledger.timestamp.saturating_add(step.advance_time);
let env = Env::from_snapshot(snapshot);
env.cost_estimate().budget().reset_unlimited();
env
};
step.command.exec(&config, &curr_env);
assert_invariants(&config, &prev_env, &curr_env);
prev_env = curr_env;
}
Convertir una prueba de fuzzing en una prueba de propiedad
Además de las pruebas de fuzzing, Soroban admite pruebas de propiedad al estilo de quickcheck, usando las crates proptest
y proptest-arbitrary-interop
junto con el trait SorobanArbitrary
.
Las pruebas de propiedad son similares a las pruebas de fuzzing en que generan entradas aleatorias. Sin embargo, las pruebas de propiedad no instrumentan sus casos de prueba ni mutan sus entradas basándose en retroalimentación de pruebas previas. Por lo tanto, son una forma más débil de prueba.
La gran ventaja de las pruebas de propiedad es que pueden incluirse en suites de pruebas Rust estándar y no requieren herramientas adicionales para ejecutarse. Se puede aprovechar esto haciendo fuzzing interactivo para descubrir bugs profundos, y luego convertir las pruebas de fuzzing a pruebas de propiedad para ayudar a prevenir regresiones.
El archivo proptest.rs
es una traducción de fuzz_target_1.rs
a una prueba de propiedad.