Fuzzing com cargo-fuzz
1. Introdução ao Fuzzing em Rust
Fuzzing é uma técnica de teste automatizado que fornece entradas aleatórias, malformadas ou inesperadas a um programa para descobrir bugs, crashes, vazamentos de memória e vulnerabilidades de segurança. Diferente dos testes unitários tradicionais, que verificam comportamentos esperados com entradas pré-definidas, o fuzzing explora o espaço de entradas possíveis de forma sistemática, buscando por comportamentos inesperados que podem comprometer a robustez do software.
No ecossistema Rust, o cargo-fuzz se destaca como a ferramenta oficial para fuzzing, integrando-se nativamente com o libFuzzer da LLVM. Enquanto testes unitários verificam casos específicos e property-based testing (com crates como proptest) explora propriedades invariantes, o fuzzing é ideal para descobrir bugs em parsers, decodificadores, funções de serialização/desserialização e qualquer código que processe entrada externa.
2. Configurando o Ambiente com cargo-fuzz
Para começar, instale o cargo-fuzz utilizando o gerenciador de pacotes do Rust:
cargo install cargo-fuzz
Em seguida, navegue até o diretório do seu projeto Rust e inicialize o fuzzing:
cargo fuzz init
Este comando cria uma estrutura de diretórios específica:
meu-projeto/
├── fuzz/
│ ├── Cargo.toml
│ └── fuzz_targets/
│ └── fuzz_target_1.rs
├── src/
│ └── main.rs
└── Cargo.toml
O arquivo fuzz/Cargo.toml gerencia as dependências exclusivas para fuzzing, enquanto fuzz/fuzz_targets/ contém os alvos de fuzzing — funções que recebem dados brutos e os utilizam para testar seu código.
3. Escrevendo seu Primeiro Fuzz Target
Um fuzz target é uma função anotada com a macro fuzz_target! que recebe um slice de bytes (&[u8]) como entrada. Vamos criar um exemplo prático fuzzeando uma função de parsing de strings que converte uma representação textual em um número:
// fuzz/fuzz_targets/fuzz_target_1.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fn parse_number(input: &str) -> Result<i32, String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("entrada vazia".to_string());
}
let is_negative = trimmed.starts_with('-');
let digits = if is_negative { &trimmed[1..] } else { trimmed };
if digits.is_empty() || !digits.chars().all(|c| c.is_ascii_digit()) {
return Err("dígitos inválidos".to_string());
}
let mut result: i32 = 0;
for c in digits.chars() {
let digit = c as i32 - '0' as i32;
result = result.checked_mul(10).ok_or("overflow")?;
result = result.checked_add(digit).ok_or("overflow")?;
}
if is_negative {
result = result.checked_neg().ok_or("overflow negativo")?;
}
Ok(result)
}
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = parse_number(s);
}
});
Note que convertemos os bytes brutos para &str usando from_utf8, pois nossa função espera uma string. Essa conversão segura evita que o fuzzer explore caminhos com bytes inválidos para UTF-8.
4. Executando e Monitorando Fuzzing
Para executar o fuzzing, use o comando:
cargo fuzz run fuzz_target_1
O fuzzer começará a gerar entradas aleatórias e testar nossa função. As estatísticas em tempo real incluem:
- cov: cobertura de código (número de blocos básicos executados)
- crashes: número de entradas que causaram panics ou crashes
- exec/s: velocidade de execução (entradas por segundo)
- ft: número de funções cobertas
Para controlar o tempo de execução:
cargo fuzz run fuzz_target_1 -- -max_total_time=60 # 60 segundos
cargo fuzz run fuzz_target_1 -- -runs=100000 # 100 mil iterações
Para utilizar múltiplos cores:
cargo fuzz run fuzz_target_1 -- -jobs=4
5. Analisando e Reproduzindo Crashes
Quando um crash é encontrado, o cargo-fuzz salva o artefato em fuzz/artifacts/fuzz_target_1/. Os arquivos têm extensões como .crash, .leak ou .oom. Para reproduzir um crash:
cargo fuzz fmt fuzz_target_1 fuzz/artifacts/fuzz_target_1/crash-xxxxx
Isso exibe o conteúdo do artefato. Podemos então criar um teste unitário para depuração:
#[test]
fn test_crash_reproduction() {
let crash_data = b"2147483648"; // Exemplo de entrada que causa overflow
if let Ok(s) = std::str::from_utf8(crash_data) {
let result = parse_number(s);
assert!(result.is_err());
}
}
Para habilitar sanitizers durante o fuzzing, adicione ao fuzz/Cargo.toml:
[profile.release]
debug = 1
[target.x86_64-unknown-linux-gnu]
rustflags = ["-Zsanitizer=address"]
6. Técnicas Avançadas de Fuzzing
Para fuzzear funções que recebem tipos estruturados, utilize o crate arbitrary:
// Cargo.toml (no diretório fuzz)
[dependencies]
arbitrary = { version = "1", features = ["derive"] }
[dev-dependencies]
libfuzzer-sys = "0.4"
use arbitrary::Arbitrary;
#[derive(Arbitrary, Debug)]
struct Config {
nome: String,
idade: u8,
ativo: bool,
}
fuzz_target!(|data: &[u8]| {
if let Ok(config) = arbitrary::Unstructured::new(data).arbitrary::<Config>() {
processa_config(config);
}
});
Para fuzzear funções que recebem &str diretamente, use o Arbitrary para gerar strings válidas:
fuzz_target!(|data: &[u8]| {
let mut unstructured = arbitrary::Unstructured::new(data);
if let Ok(s) = unstructured.arbitrary::<String>() {
let _ = parse_number(&s);
}
});
Dictionaries ajudam o fuzzer a gerar entradas mais relevantes. Crie um arquivo fuzz/fuzz_target_1.dict:
kw1="+"
kw2="-"
kw3="0"
kw4="2147483647"
E execute com:
cargo fuzz run fuzz_target_1 -- -dict=fuzz/fuzz_target_1.dict
7. Integração Contínua e Boas Práticas
Para adicionar fuzzing ao GitHub Actions:
name: Fuzzing
on: [push, pull_request]
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
- run: cargo install cargo-fuzz
- run: cargo fuzz run fuzz_target_1 -- -runs=10000
Para fuzzing contínuo, considere serviços como OSS-Fuzz (mantido pelo Google) ou configure cron jobs que executam o fuzzer por períodos prolongados.
Limitações importantes: fuzzing não garante cobertura total, pode gerar falsos positivos (especialmente com sanitizers) e é computacionalmente caro. Combine com testes unitários e property-based testing para cobertura abrangente.
8. Estudo de Caso: Fuzzeando um Parser JSON
Vamos fuzzear um parser JSON simplificado:
// fuzz/fuzz_targets/json_parser.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fn parse_json_simple(input: &str) -> Result<(), String> {
let input = input.trim();
if input.is_empty() {
return Err("vazio".to_string());
}
let bytes = input.as_bytes();
let mut i = 0;
// Pula espaços
while i < bytes.len() && bytes[i] == b' ' { i += 1; }
match bytes[i] {
b'{' => parse_object(bytes, &mut i),
b'[' => parse_array(bytes, &mut i),
b'"' => parse_string(bytes, &mut i),
_ if bytes[i].is_ascii_digit() || bytes[i] == b'-' => parse_number(bytes, &mut i),
_ => Err("token inválido".to_string())
}
}
fn parse_object(bytes: &[u8], i: &mut usize) -> Result<(), String> {
*i += 1; // pula '{'
loop {
while *i < bytes.len() && bytes[*i] == b' ' { *i += 1; }
if *i >= bytes.len() { return Err("fim inesperado".to_string()); }
if bytes[*i] == b'}' { *i += 1; return Ok(()); }
parse_string(bytes, i)?;
while *i < bytes.len() && bytes[*i] == b' ' { *i += 1; }
if *i >= bytes.len() || bytes[*i] != b':' { return Err("esperado :".to_string()); }
*i += 1;
parse_value(bytes, i)?;
while *i < bytes.len() && bytes[*i] == b' ' { *i += 1; }
if *i >= bytes.len() { return Err("fim inesperado".to_string()); }
if bytes[*i] == b'}' { *i += 1; return Ok(()); }
if bytes[*i] != b',' { return Err("esperado ,".to_string()); }
*i += 1;
}
}
fn parse_array(bytes: &[u8], i: &mut usize) -> Result<(), String> {
*i += 1; // pula '['
loop {
while *i < bytes.len() && bytes[*i] == b' ' { *i += 1; }
if *i >= bytes.len() { return Err("fim inesperado".to_string()); }
if bytes[*i] == b']' { *i += 1; return Ok(()); }
parse_value(bytes, i)?;
while *i < bytes.len() && bytes[*i] == b' ' { *i += 1; }
if *i >= bytes.len() { return Err("fim inesperado".to_string()); }
if bytes[*i] == b']' { *i += 1; return Ok(()); }
if bytes[*i] != b',' { return Err("esperado ,".to_string()); }
*i += 1;
}
}
fn parse_string(bytes: &[u8], i: &mut usize) -> Result<(), String> {
if *i >= bytes.len() || bytes[*i] != b'"' {
return Err("esperado \"".to_string());
}
*i += 1;
while *i < bytes.len() && bytes[*i] != b'"' {
if bytes[*i] == b'\\' { *i += 1; }
*i += 1;
}
if *i >= bytes.len() { return Err("string não fechada".to_string()); }
*i += 1; // pula '"'
Ok(())
}
fn parse_number(bytes: &[u8], i: &mut usize) -> Result<(), String> {
if *i >= bytes.len() { return Err("fim inesperado".to_string()); }
if bytes[*i] == b'-' { *i += 1; }
while *i < bytes.len() && bytes[*i].is_ascii_digit() { *i += 1; }
Ok(())
}
fn parse_value(bytes: &[u8], i: &mut usize) -> Result<(), String> {
while *i < bytes.len() && bytes[*i] == b' ' { *i += 1; }
if *i >= bytes.len() { return Err("fim inesperado".to_string()); }
match bytes[*i] {
b'{' => parse_object(bytes, i),
b'[' => parse_array(bytes, i),
b'"' => parse_string(bytes, i),
_ if bytes[*i].is_ascii_digit() || bytes[*i] == b'-' => parse_number(bytes, i),
_ => Err("valor inválido".to_string())
}
}
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = parse_json_simple(s);
}
});
Ao executar o fuzzing, descobrimos rapidamente que entradas como {"a": causam um crash devido a acesso fora dos limites. O fuzzer revelou que nosso parser não verifica corretamente os limites do array após o parsing de um valor em um objeto. Corrigimos adicionando verificações de tamanho antes de acessar bytes[*i].
Comparado com testes unitários tradicionais, que testariam apenas alguns casos esperados, o fuzzer explorou milhares de combinações inesperadas, encontrando bugs que jamais seriam descobertos manualmente. O property-based testing com proptest poderia ajudar, mas o fuzzing é mais eficaz para encontrar crashes específicos causados por entradas malformadas.
Referências
- Documentação oficial do cargo-fuzz — Guia completo de instalação, configuração e uso do cargo-fuzz para fuzzing em Rust.
- libFuzzer – A Library for Coverage-Guided Fuzz Testing — Documentação oficial do libFuzzer, o motor de fuzzing utilizado pelo cargo-fuzz.
- The Rust Fuzz Book — Livro abrangente sobre fuzzing em Rust, cobrindo cargo-fuzz, AFL e técnicas avançadas.
- OSS-Fuzz: Continuous Fuzzing for Open Source Software — Serviço do Google para fuzzing contínuo de projetos open source, com suporte nativo a Rust.
- Arbitrary crate documentation — Documentação do crate
arbitrary, utilizado para gerar dados estruturados durante o fuzzing. - Property Testing with proptest — Introdução ao property-based testing em Rust, abordagem complementar ao fuzzing para descoberta de bugs.