Introdução ao fuzzing para encontrar bugs de segurança e estabilidade
1. O que é fuzzing e por que ele é essencial
Fuzzing, ou teste de fuzzing, é uma técnica de teste de software que consiste em fornecer entradas inválidas, inesperadas ou aleatórias a um programa para detectar falhas de segurança e estabilidade. Diferente dos testes tradicionais, que verificam cenários previsíveis e esperados, o fuzzing explora o comportamento do software diante de dados malformados, expondo vulnerabilidades como buffer overflow, vazamento de memória, injeção de código e crashes inesperados.
A técnica surgiu na década de 1990, quando Barton Miller, da Universidade de Wisconsin, começou a testar a robustez de utilitários Unix enviando caracteres aleatórios. Desde então, o fuzzing evoluiu para se tornar uma prática fundamental no desenvolvimento seguro de software, especialmente em sistemas que processam entrada de usuários, como parsers, protocolos de rede e formatos de arquivo.
Os benefícios principais do fuzzing incluem:
- Descoberta de bugs de segurança que passariam despercebidos em testes manuais
- Identificação de instabilidades e deadlocks que afetam a experiência do usuário
- Redução do custo de correção ao encontrar falhas antes da liberação do software
2. Tipos de fuzzing: abordagens clássicas
Existem duas abordagens fundamentais de fuzzing:
Fuzzing baseado em mutação (mutational): Parte de entradas válidas conhecidas e aplica mutações incrementais — inversão de bits, inserção de caracteres especiais, truncamento ou duplicação de dados. É eficiente quando se dispõe de um corpus inicial representativo.
Fuzzing baseado em geração (generational): Cria entradas a partir de gramáticas, modelos ou especificações formais. Permite explorar estruturas complexas, como protocolos de rede ou formatos de arquivo, mas exige conhecimento prévio do domínio.
A evolução mais significativa foi o fuzzing inteligente guiado por cobertura (coverage-guided). Nessa abordagem, o fuzzer instrumenta o código para monitorar quais caminhos de execução foram percorridos. Entradas que revelam novas coberturas são priorizadas, direcionando as mutações para áreas ainda inexploradas.
3. Ferramentas populares de fuzzing no ecossistema moderno
AFL (American Fuzzy Lop) e AFL++: São referências no fuzzing guiado por cobertura. Utilizam instrumentação de código em tempo de compilação para rastrear transições entre blocos básicos. O AFL++ é a versão mantida pela comunidade, com suporte a múltiplos sanitizers e integração com LLVM.
LibFuzzer: Ferramenta de fuzzing in-process integrada ao ecossistema LLVM. Permite escrever targets de fuzzing como funções C/C++ que recebem um buffer de dados. É leve, rápida e excelente para testar funções específicas de parsing.
OSS-Fuzz: Plataforma do Google que oferece fuzzing contínuo para projetos open source. Executa milhares de instâncias simultâneas, gerencia corpora e notifica mantenedores sobre bugs descobertos. Projetos como OpenSSL, libpng e SQLite utilizam OSS-Fuzz.
4. Configurando um ambiente básico de fuzzing
Para demonstrar o fuzzing na prática, vamos construir um target simples para uma função de parsing de números.
Pré-requisitos: Compilador Clang com suporte a sanitizers e LibFuzzer.
Código do target (arquivo: parse_number.c):
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
int parse_number(const uint8_t *data, size_t size) {
if (size < 3) {
return -1;
}
char buffer[4];
memcpy(buffer, data, 3);
buffer[3] = '\0';
int num = atoi(buffer);
if (num < 0) {
return -2;
}
if (num > 999) {
// Simulação de bug: buffer overflow intencional
char overflow[4];
memcpy(overflow, buffer, 10); // Erro proposital
}
return num;
}
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
parse_number(data, size);
return 0;
}
Compilação com instrumentação:
clang -fsanitize=fuzzer,address -g -O1 parse_number.c -o parse_number_fuzz
Execução inicial:
./parse_number_fuzz -max_len=10 -runs=10000
O fuzzer gerará entradas aleatórias e, ao encontrar um crash, salvará o arquivo responsável no diretório crash-*. A saída típica exibe informações sobre cobertura e velocidade:
INFO: Seed: 12345678
INFO: Loaded 0 modules (1 inline 8-bit counters)
INFO: -max_len is not provided; using 10
#1000 cov: 45 ft: 78 corp: 12 exec/s: 5000 rss: 28MB
#2000 cov: 52 ft: 102 corp: 18 exec/s: 4800 rss: 28MB
#3000 cov: 58 ft: 134 corp: 22 exec/s: 5100 rss: 28MB
5. Identificando e analisando bugs descobertos
Quando o fuzzer encontra um crash, ele gera um arquivo binário com a entrada causadora. Para reproduzir e analisar:
./parse_number_fuzz crash-1234567890abcdef
Com o AddressSanitizer ativado, a saída mostrará detalhes da violação de memória:
==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffe...
WRITE of size 10 at 0x7ffe... thread T0
#0 0x4a1b2c in parse_number parse_number.c:18:9
#1 0x4a1d0e in LLVMFuzzerTestOneInput parse_number.c:25:5
#2 0x4a1f00 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long)
Para priorizar bugs, classifique:
- Críticos: Buffer overflow, uso após liberação, injeção de código
- Instabilidades: Deadlocks, loops infinitos, vazamento de memória
A validação manual é essencial para confirmar se o crash é explorável e se representa um risco real.
6. Integração do fuzzing em pipelines de CI/CD
Para integrar fuzzing em pipelines contínuos:
Estratégia de execução limitada por tempo:
./parse_number_fuzz -max_total_time=300 -jobs=4 -workers=4
Gerenciamento de corpora: Mantenha um diretório com entradas minimizadas e deduplicadas. Use a flag -merge=1 para combinar corpora:
./parse_number_fuzz -merge=1 corpus_new/ corpus_existing/
Combinação com testes tradicionais: O fuzzing complementa testes unitários e BDD, não os substitui. Enquanto testes unitários verificam comportamentos esperados, o fuzzing explora o inesperado. Exemplo de pipeline no GitHub Actions:
name: Fuzzing CI
on: [push, pull_request]
jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build with fuzzer
run: clang -fsanitize=fuzzer,address -g -O1 parse_number.c -o fuzz
- name: Run fuzzer
run: ./fuzz -max_total_time=60 -runs=5000
7. Limitações e boas práticas no uso de fuzzing
Limitações:
- Cobertura limitada para lógicas complexas que dependem de estados internos profundos (ex: máquinas de estado com múltiplas transições)
- Falsos positivos: algumas entradas podem causar crashes apenas em condições específicas de hardware ou sistema operacional
- Dificuldade em testar sistemas que exigem interação com rede ou bancos de dados externos
Boas práticas:
- Comece com fuzzing em funções críticas de parsing e processamento de entrada
- Evolua gradualmente para sistemas completos, utilizando técnicas como fuzzing de API ou fuzzing de protocolo
- Utilize múltiplos sanitizers (AddressSanitizer, UndefinedBehaviorSanitizer, MemorySanitizer) para detectar diferentes classes de bugs
- Mantenha um corpus de entrada minimizado e versionado no repositório
- Documente cada crash descoberto, incluindo entrada reproduzível e análise de causa raiz
O fuzzing não é uma bala de prata, mas quando combinado com revisão de código, testes tradicionais e análise estática, forma uma defesa robusta contra bugs de segurança e instabilidade.
Referências
- AFL++ Documentation — Documentação oficial do AFL++ com guias de instalação, uso e configuração avançada.
- LibFuzzer Tutorial — Tutorial oficial da LLVM sobre LibFuzzer, incluindo exemplos de targets e integração com sanitizers.
- OSS-Fuzz: Continuous Fuzzing for Open Source Software — Site oficial do OSS-Fuzz com documentação sobre como integrar projetos na plataforma.
- Fuzzing for Security: A Practical Guide — Guia prático da OWASP sobre fuzzing, abordando tipos, ferramentas e boas práticas.
- AddressSanitizer: Fast Address Sanity Checker — Documentação do AddressSanitizer, essencial para análise de bugs descobertos por fuzzing.
- Fuzzing: The Art of Finding Bugs — Livro online interativo sobre fuzzing, cobrindo desde conceitos básicos até técnicas avançadas.