Pular para o conteúdo

Trilha de Go · parte 16 de 37

Fuzzing nativo em Go: testes guiados por cobertura

O que é fuzzing guiado por cobertura, como escrever funções FuzzXxx e como o Go transforma uma falha em um caso de teste reproduzível.

Publicada em · 10 min de leitura

Tamanho da fonte:
Largura da coluna:

Ao escrever testes unitários tradicionais ou tabelas de teste (Table-Driven Tests e Paralelismo), nós só conseguimos testar os cenários que nossa própria imaginação consegue conceber: strings formatadas, números positivos comuns, zero e talvez uma string vazia.

No entanto, em ambientes reais de produção conectados à internet, nossos sistemas recebem payloads caóticos: caracteres Unicode malformados, sequências de escape incompletas, bytes nulos injetados no meio de streams e inteiros negativos extremos. Quase todas as vulnerabilidades de segurança graves (como estouro de buffer, negação de serviço por pânico e injeção de comandos) nascem em casos extremos que nenhum desenvolvedor previu.

Para resolver esse problema, o Go 1.18 introduziu suporte nativo de primeira classe a Fuzz Testing (Fuzzing) diretamente na biblioteca padrão e na ferramenta de linha de comando go test.

Esta aula ensina o que é Fuzzing guiado por cobertura, como estruturar funções FuzzXxx(f *testing.F), quais estratégias de validação utilizar quando você não conhece o resultado de antemão e como o Go gerencia a regressão automática de bugs com a pasta testdata/fuzz.


O Mapa da Aula

ParteSeçãoO que você vai dominar
1. Fundamentos1 e 2O que é Fuzzing, por que ele supera testes manuais e a instrumentação Coverage-Guided
2. Anatomia3 e 4A struct testing.F, o Seed Corpus inicial com f.Add e os tipos suportados
3. Estratégias de Validação5 e 6As 4 formas de validar saídas aleatórias (Crash-free, Round-trip, Referência e Invariantes)
4. O Ciclo da Falha7 e 8Minimização de payloads, a pasta testdata/fuzz e regressão automática em pipelines de CI
5. Caso Prático Real9 e 10Dissecando o bug clássico de UTF-8 descoberto em 0.02s pelo Fuzzer do Go, comandos úteis e checklist

Pré-requisitos recomendados: Table-Driven Tests e Paralelismo.


Parte 1: O que é Coverage-Guided Fuzzing

1. Testes Baseados em Exemplo vs Fuzzing

Em testes unitários clássicos, você fornece uma entrada específica e espera uma saída exata:

Entrada: "Go"  ───>  Função  ───>  Saída Esperada: "oG"

No Fuzz Testing, o computador assume o papel de testador hostil: ele gera milhões de mutações de dados contínuas e aleatórias para tentar quebrar a sua função:

Mutações geradas: "\x00\xff", "   ", "%s%s", "\uFFFD", "日本語", -9223372036854775808

2. Como funciona o mecanismo Coverage-Guided

O Go não gera lixo aleatório às cegas. O compilador utiliza uma técnica científica chamada Coverage-Guided Fuzzing (Fuzzing Guiado por Cobertura):

coverage guided fuzzing
figura 1 · coverage guided fuzzing
  1. O compilador instrumenta o binário de testes, monitorando cada desvio condicional (if, switch, loops) do seu código.
  2. O fuzzer começa executando os exemplos iniciais que você forneceu no Seed Corpus.
  3. O motor de mutação altera bits, inverte bytes e insere caracteres especiais.
  4. Se uma mutação executar e percorrer apenas os mesmos caminhos de código já conhecidos, o fuzzer descarta aquele dado.
  5. Se uma mutação atingir um novo branch de código que nunca havia sido executado antes (por exemplo, entrar dentro de um if err != nil obscuro), o Go considera essa entrada “interessante” e a salva no corpus. A partir daí, o mutador usa essa nova entrada como base para gerar as próximas mutações!
  6. Esse ciclo continua até encontrar um pânico (panic), travamento ou violação de regra de negócio.

Parte 2: Anatomia de um Fuzz Test

3. A estrutura com testing.F

Fuzz tests residem nos arquivos _test.go normais, mas com assinatura e estrutura específicas:

func FuzzMinhaFuncao(f *testing.F) {
    // 1. Seed Corpus: Exemplos conhecidos e confiáveis para guiar o fuzzer
    f.Add("exemplo1", 10)
    f.Add("exemplo2", 20)

    // 2. Fuzz Target: A função executada repetidamente com dados mutantes
    f.Fuzz(func(t *testing.T, texto string, numero int) {
        // Código sob teste executado com os dados aleatórios
        res, err := MinhaFuncao(texto, numero)
        
        // Asserções de segurança e invariantes
        if err == nil && res == "" {
            t.Errorf("resposta vazia sem erro para entrada %q", texto)
        }
    })
}

Regras estruturais obrigatórias:

  • A função deve iniciar com o prefixo Fuzz e receber f *testing.F.
  • Todas as chamadas de inicialização f.Add(...) devem acontecer antes de f.Fuzz(...).
  • O f.Fuzz(...) recebe uma função anônima cujo primeiro argumento é obrigatoriamente t *testing.T, seguido pelos tipos que devem ser mutados.
  • Cada arquivo de teste pode conter múltiplos testes Test..., Benchmark... e Fuzz... convivendo harmoniosamente.

4. Tipos de dados suportados pelo Fuzzer nativo

O target de fuzzing do Go aceita apenas tipos primitivos que o motor sabe como serializar e mutar:

  • string
  • []byte
  • int, int8, int16, int32 (rune), int64
  • uint, uint8 (byte), uint16, uint32, uint64
  • float32, float64
  • bool

Parte 3: As 4 Estratégias de Validação (Oráculos de Teste)

Em testes tradicionais, você valida comparando got == want. Mas no Fuzzing, a entrada é imprevisível (ex: "\x99\xfa\x00"). Como saber qual é o resultado correto sem calcular manualmente?

Utilizamos propriedades invariantes (oráculos de teste):

estrategias validacao fuzzing
figura 2 · estrategias validacao fuzzing

1. Estratégia Crash-Free (Anti-Pânico)

A propriedade mais básica e poderosa: não importa o lixo que o usuário ou a rede envie para a sua função, ela nunca deve disparar um pânico de runtime nem dar estouro de índice (index out of range ou nil pointer dereference). Ela deve processar com sucesso ou retornar um error limpo e controlado.

2. Estratégia Round-Trip (Invertibilidade)

Ideal para serializadores, parsers, codecs e criptografia: se você codificar um dado e em seguida decodificá-lo, o resultado deve ser obrigatoriamente idêntico ao dado original:

$$\text{Decodificar}(\text{Codificar}(x)) == x$$

Exemplos clássicos: json.Unmarshal(json.Marshal(x)), compactadores zip, codificadores base64, conversores de coordenadas e parsers de URL.

3. Estratégia do Algoritmo de Referência

Se você escreveu uma versão nova, super otimizada e concorrente de um algoritmo complexo (por exemplo, um classificador ou busca binária customizada), rode o algoritmo novo e um algoritmo simples/lento com a mesma entrada gerada pelo fuzzer e garanta que ambos devolvem exatamente o mesmo resultado:

$$\text{AlgoritmoOtimizado}(x) == \text{AlgoritmoSimples}(x)$$

4. Invariantes de Negócio

Propriedades matemáticas ou regras que devem ser universalmente verdadeiras para qualquer entrada:

  • O saldo final de uma conta bancária nunca pode ser negativo.
  • Uma função que remove espaços nunca pode devolver uma string de comprimento maior que a string original: len(Sanitizar(s)) <= len(s).
  • Uma função de ordenação de lista deve sempre retornar uma lista com o mesmo número de elementos da entrada e onde cada elemento $i \le i+1$.

Parte 4: O Ciclo da Falha e a Pasta testdata/fuzz

Quando o fuzzer encontra uma entrada que quebra sua asserção ou dispara um pânico, o Go executa um processo automático fascinante:

7. Minimização do Payload (Minimizing)

Se o fuzzer encontrou uma quebra usando uma string gigante de 2.000 bytes gerada aleatoriamente, ele não joga aquele bloco ilegível no seu colo. Ele inicia uma fase chamada Minimização:

  • Ele corta partes do texto repetidamente testando se a falha ainda ocorre.
  • Ele reduz o payload ao menor número de bytes possível que ainda reproduz o erro (por exemplo, reduzindo 2.000 bytes a apenas 2 bytes: "\x00\n").

8. A Pasta testdata/fuzz e Regressão Automática em CI

O Go salva imediatamente o caso que quebrou em um arquivo permanente no disco:

seu_projeto/
├── stringutil.go
├── stringutil_test.go
└── testdata/
    └── fuzz/
        └── FuzzInverter/
            └── a406202f52a84d42   <-- Arquivo de regressão com o payload causador

A mágica da regressão automática:

Quando você rodar o comando normal de testes em sua máquina ou no pipeline de CI:

go test ./...

O Go não rodará o fuzzer por minutos a fio. Em vez disso, ele lê automaticamente todos os arquivos salvos dentro de testdata/fuzz e os executa como testes unitários instantâneos normais!

Isso garante que um bug descoberto pelo fuzzer jamais voltará a acontecer no projeto sem ser detectado, criando uma barreira de proteção permanente contra regressões.


Parte 5: Estudo de Caso Real: A Armadilha do UTF-8

Vamos analisar um caso clássico onde o Fuzzer do Go encontrou um bug sutil em apenas 0.02 segundos.

O Código sob Teste (stringutil.go):

Queremos implementar uma função que inverte strings:

package stringutil

// Inverter inverte os caracteres de uma string.
func Inverter(s string) string {
	runes := []rune(s)
	for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
		runes[i], runes[j] = runes[j], runes[i]
	}
	return string(runes)
}

À primeira vista, o código parece impecável: usamos []rune para respeitar caracteres multi-byte como acentos e emojis.

O Teste com Fuzzing (stringutil_test.go):

Aplicamos a estratégia de Round-Trip (inverter a string duas vezes deve devolver a string original):

package stringutil_test

import (
	"testing"
	"meu-modulo/stringutil"
)

func FuzzInverter(f *testing.F) {
	// Seed Corpus
	f.Add("hello")
	f.Add("Go")
	f.Add("Olá, Mundo! 🚀")

	f.Fuzz(func(t *testing.T, original string) {
		invertida := stringutil.Inverter(original)
		duasVezes := stringutil.Inverter(invertida)

		if original != duasVezes {
			t.Fatalf("Round-trip falhou: esperava %q, recebeu %q", original, duasVezes)
		}
	})
}

O que o Fuzzer Descobriu em 0.02 segundos:

Ao rodar go test -fuzz=FuzzInverter, o terminal acusa erro quase instantaneamente:

fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: minimizing 58-byte failing input file
--- FAIL: FuzzInverter (0.02s)
    --- FAIL: FuzzInverter (0.00s)
        stringutil_test.go:19: Round-trip falhou: esperava "\x99", recebeu ""
    
    Failing input written to testdata/fuzz/FuzzInverter/a406202f52a84d42
FAIL

A Explicação do Bug:

O fuzzer gerou o byte \x99. O byte \x99 é um byte inválido isolado na codificação UTF-8.

No Go, quando uma string contém bytes que violam a especificação UTF-8 e você a converte para []rune, o Go substitui o byte quebrado pelo caractere especial de erro Unicode: utf8.RuneError (\uFFFD, exibido visualmente como “).

Logo, a string original de 1 byte "\x99" transformou-se na runa de substituição "". Ao inverter duas vezes, recebemos "", e não "\x99". O Fuzzer provou que nossa premissa de round-trip só é válida se a entrada for um texto UTF-8 válido!

A Correção no Teste:

f.Fuzz(func(t *testing.T, original string) {
    // Se a entrada gerada aleatoriamente não for UTF-8 válido,
    // o Go modificará os bytes na conversão para []rune. Ignoramos esse caso:
    if !utf8.ValidString(original) {
        return
    }

    invertida := stringutil.Inverter(original)

    // Invariante de integridade: a saída deve permanecer UTF-8 válido
    if !utf8.ValidString(invertida) {
        t.Fatalf("produziu UTF-8 inválido: %q", invertida)
    }

    duasVezes := stringutil.Inverter(invertida)
    if original != duasVezes {
        t.Fatalf("Round-trip falhou: esperava %q, recebeu %q", original, duasVezes)
    }
})

Após esse ajuste, o fuzzer executa centenas de milhares de mutações por segundo com sucesso:

fuzz: elapsed: 3s, execs: 187270 (62399/sec), new interesting: 26 (total: 33)
PASS
ok      meu-modulo      4.257s

Parte 6: Comandos Úteis do Fuzzing

  • Executar o fuzzer por tempo determinado:
    # Roda o fuzzing por 30 segundos
    go test -fuzz=FuzzMinhaFuncao -fuzztime=30s
  • Executar por número de iterações:
    # Roda exatamente 500.000 mutações
    go test -fuzz=FuzzMinhaFuncao -fuzztime=500000x
  • Controlar tempo de minimização de falha:
    go test -fuzz=FuzzMinhaFuncao -fuzzminimizetime=15s
  • Rodar apenas o caso de regressão salvo:
    go test -run=FuzzMinhaFuncao/a406202f52a84d42

Parte 7: Checklist de Qualidade em Fuzzing

  1. Sempre forneça um Seed Corpus variado com f.Add: Inclua casos comuns, vazios, números negativos e caracteres especiais. O fuzzer usa esses exemplos como ponto de partida para suas mutações.
  2. Filtre entradas inválidas no início do target: Se a sua função só opera sobre dados que respeitam uma pré-condição externa (ex: apenas UTF-8 válido, ou apenas inteiros maiores que zero), faça um if !valido { return } antes de processar.
  3. Commit da pasta testdata/fuzz no Git: Nunca coloque testdata no .gitignore! Os arquivos de falha gerados pelo fuzzer são testes de regressão valiosos que devem ser versionados e executados por toda a equipe.
  4. Agende Fuzzing contínuo em pipelines noturnos: Em vez de rodar o fuzzer por 1 hora a cada commit, configure uma pipeline agendada (ex: cron noturno no GitHub Actions) para rodar go test -fuzz=. -fuzztime=30m todas as noites.

Exercício Prático Proposto

  1. Crie uma função SanitizarSlug(titulo string) string que transforme qualquer título em um slug de URL (minúsculas, substituição de espaços por traços - e remoção de caracteres de pontuação).
  2. Escreva um teste de fuzzing FuzzSanitizarSlug validando três invariantes universais:
    • A saída nunca contém espaços em branco.
    • A saída é sempre UTF-8 válido.
    • O comprimento da saída é sempre menor ou igual ao comprimento do título original.
  3. Execute o fuzzer por 10 segundos com go test -fuzz=FuzzSanitizarSlug -fuzztime=10s.

↑ Voltar ao topo

Atalhos de teclado

Abrir busca
/
Alternar tema claro/escuro
t
Aula anterior na série
←
Próxima aula na série
→
Lista de atalhos
?