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