Teste de fuzzing: faça testes automatizados com Fuzzers

Neste artigo você vai ver:

Existem vários tipos de teste de software que aumentam a segurança e a qualidade do produto final. Mas poucos são tão interessantes (e pouco explorados) como o Teste de Fuzzing.

Conheça neste artigo o que Teste de Fuzzing, seu conceito e sua origem, além do que o seu processo de desenvolvimento de software ganha adotando esse tipo de teste.

Teste de software

O teste é uma atividade chave do processo de desenvolvimento de software. Há diversos estudos que apontam que essa atividade é responsável por uma parcela significativa de todo o esforço do processo de desenvolvimento de software.

Há diversas abordagens de teste de software, como teste de unidade, teste de integração, teste de sistema, teste de performance, teste de aceitação, dentre vários outros

Independente da técnica, de maneira geral, as abordagens de teste têm como objetivo executar um dado programa com a intenção de inspecionar o seu comportamento interno. Caso o resultado do teste não esteja de acordo com a especificação do programa, é dito que o teste encontrou um defeito no código.

Os testes podem ser executados manualmente ou automaticamente. Embora à primeira vista testes manuais sejam mais fáceis de serem executados, ao longo do tempo, estes testes se tornam tediosos e custosos, pois exigem um esforço repetitivo da equipe de desenvolvimento e de testes. 

Teste de software automatizados

Testes automatizados, por sua vez, tentam minimizar esse esforço através da criação de conjuntos de testes (ou, como são chamados, suíte de testes) que exercem um determinado conjunto de instruções de um programa. 

Para isso, times de desenvolvimento e de teste que criam estas suítes precisam se preocupar com a manutenção e evolução da suíte, bem como ter a cultura de executá-la após cada mudança na base de dados  (potencialmente conectadas em um serviço de integração contínua na nuvem), e verificar se o resultado apontado pela suíte de testes está de acordo com a especificação do programa. 

Embora exista um grande número de bibliotecas e frameworks utilizados para criação de testes automatizados, um significante número destas bibliotecas implementam a abordagem de teste de unidade, que tem como objetivo testar a menor unidade do código (ou grupos destas unidades). 

Teste de unidade

De fato, a prática de teste de unidade se tornou comum no processo de desenvolvimento de software. Há diversos artigos científicos, textos em blogs e livros dedicados para evangelizar e alavancar a abordagem de teste de unidade na prática do desenvolvimento de software.

No entanto, os testes de unidade tem uma importante limitação: Para que o teste de unidade seja eficaz, o time de desenvolvimento precisa saber exatamente quais parâmetros precisam ser passados para o teste. 

Exemplo das limitações de testes de unidade

Considere o exemplo a seguir:

@Test 
@Transactional
void cadastraAlunoQuandoEleNaoExistir() {
     var aluno = new Aluno(“Gustavo Pinto”, “gustavo.pinto@zup.com.br”);
     alunoRepository.save(aluno);

     var alunoRetornado = alunoRepository.findByEmail(“gustavo.pinto@zup.com.br”)

     assertNotNull(alunoRetornado);
     assertEquals(1, alunoRetornado.getId()); 
}

O teste de unidade acima simula uma simples regra de negócio. Primeiro, o teste salva os dados de um aluno no banco de dados. Depois disso, busca o aluno recém inserido, verifica se este aluno não está nulo e se o identificador do aluno é igual a 1 (significando que foi o primeiro registro na base de dados). 

Mas para isso, precisamos construir o objeto (var aluno) com as informações necessárias.

No entanto, não é responsabilidade deste teste saber se o nome do aluno não está em branco, se o aluno forneceu um e-mail válido ou se o telefone tem um formato válido. Geralmente estas validações são facilmente implementadas em outros locais, como na interface de formulário que recebe estes dados.

Imagine, porém, que precisamos fazer validações mais sofisticadas. 

Além de salvar dados de um aluno, precisamos também salvar o código-fonte de um exercício resolvido por ele. Imagine também que nossa aplicação tem interesse em rodar uma suíte de testes preparada pelo professor contra todas as soluções postadas pelos alunos. Para isso, precisamos garantir que o aluno só faça upload de programas válidos (de acordo com uma determinada linguagem de programação). 

Que tipo de testes podemos utilizar para termos essa garantia?

O que é Teste de fuzzing?

Fuzzing é uma técnica de teste automatizado que tem como objetivo testar a robustez de programas através da geração de entradas inválidas. A ideia desta estratégia de teste é entender como nossos programas se comportam na presença de entradas inesperadas. 

Fuzzers, por outro lado, são ferramentas de testes que implementam os princípios do testes de fuzzing. Ao trabalhar com teste de fuzzing, estamos interessados em usar (ou construir) ferramentas — nossos fuzzers — que apoiam na atividade de teste automatizados.

Origem do teste de fuzzing

O teste de fuzzing não é necessariamente novo. O termo “fuzzing” foi cunhado pelo pesquisador Barton Miller, em 1988. Durante uma noite chuvosa, o professor (que estava em sua casa conectado ao seu computador da universidade através de uma conexão discada) notou que os trovões estavam interferindo na qualidade da sua conexão, o que, por sua vez, estava fornecendo entradas mal-formatadas nos programas UNIX que ele executava em sua máquina na universidade

Após sistematicamente testar diversos programas UNIX com parâmetros mal-formatados, o professor e seu grupo de estudantes descobriram defeitos em 24% dos 90 programas utilitários do UNIX que foram testados na pesquisa (publicada em 1990)

Curiosamente, 30 anos após este estudo inicial, o professor Barton Miller e colegas decidiram refazer a pesquisa, agora utilizando utilitários do Linux, FreeBSD e MacOS. Os resultados do estudo de 2020 foram ainda mais impressionantes: o teste de fuzzing foi capaz de encontrar defeitos em 24 diferentes utilitários nas 3 diferentes plataformas

O teste de fuzzing já provou encontrar defeitos em diversos contextos, não somente em estudos acadêmicos: 

Mas como uma abordagem de teste aleatória pode ser tão eficaz?

Fuzzing na prática

De maneira simplificada, os primeiros exemplos de teste de fuzzing realizam modificações aleatórias em um conjunto de dados. 

Primeiro experimento

Para um primeiro experimento, podemos nós mesmos criar um fuzzer simples. Para isso, vamos utilizar o exemplo fornecido no Fuzzing Book

def fuzzer(max_length: int = 100, char_start: int = 32, char_range: int = 32) -> str:
    """A string of up to `max_length` characters
       in the range [`char_start`, `char_start` + `char_range`)"""
    string_length = random.randrange(0, max_length + 1)
    out = ""
    for i in range(0, string_length):
        out += chr(random.randrange(char_start, char_start + char_range))
    return out

Ao executar a função “fuzer()” no interpretador Python, teremos um resultado do tipo: “’,<$ )(7:211):+#:4.$!&(,?5 &%#:05: *64-“!>)99-;6?!1=<=+2+%$?/6&==&4;0=<;/73>>3,..7,<”

Podemos experimentar com os parâmetros da função fuzzer, por exemplo, indicando a quantidade uma entrada de até 100 caracteres, somente com as letras do alfabeto.

Um possível resultado dessa função seria a string: “dpwvufvirgwxctxgdxuybyjxbktsftvxtaokiynmnrifsuhwybikybuxockbsaenktdyhhko”.

Brinque um pouco com a função fuzz() e entenda a saída. 

Segundo experimento

Em seguida, que tal se nós rodássemos esse script 100x e salvássemos todas as saídas em arquivos separados (algo como fuzz-1, fuzz-2, fuzz-3 etc)? Assim poderíamos começar a usar essas saídas para testar alguns programas.

Vamos começar com o programa “bc” (que implementa uma calculadora) para fazer nossos primeiros testes.  Para isso, basta chamar: “bc fuzz*”

Com esses argumentos, o programa bc potencialmente lançará alguns erros de sintaxe (uma vez que entradas têm chances de serem expressões aritméticas válidas). 

$ bc fuzz-*
fuzz-1 1: syntax error
fuzz-1 2: syntax error
fuzz-1 3: syntax error
fuzz-2 1: illegal character: \213
fuzz-2 1: syntax error
fuzz-2 1: illegal character: ^E
fuzz-3 1: illegal character: \312
fuzz-3 1: illegal character: \264
…

Se você experimentou com um grande número de arquivos diferentes, é até possível que tenha identificado que o bc tenha travado com base em alguma entrada fornecida. A beleza do teste de fuzzing é que facilmente conseguimos escalar a quantidade de dados que passamos por entrada para nossos programas em teste. 

Com o que aprendemos até aqui, já é possível explorar a robustez de diversos programas de linha de comando. Os utilitários mais comuns que acompanham os derivados do Unix já foram explorados exaustivamente por alguns estudos empíricos, mas há grandes chances que o seu CLI favorito (seja o docker, npm, gh, etc) ainda não tenha sido testado extensivamente.

Todavia, a entrada esperada pelo bc não é necessariamente o tipo de entrada que estamos fornecendo. Nossas entradas inválidas talvez até sejam capazes de encontrar alguma exceção não tratada, mas dificilmente conseguiremos exercitar alguma regra de negócio, pelo simples fato de não conseguirmos passar da parte inicial de validação dos dados. 

Como podemos passar entradas que sejam aleatórias mas que, ainda assim, estejam no padrão esperado pelo bc?

Gerando entradas mais inteligentes 

Como sabemos que o programa bc realiza operações matemáticas, se quisermos explorar o ele de maneira mais interessante, precisamos fornecer entradas esperadas pelo programa. 

Uma forma de fazer isso é criar entradas válidas, e, em seguida, realizar pequenas mudanças nessas entradas. Veja o exemplo abaixo:

$ echo "1 + (sqrt(3.14 * 18) ^2)" | bc
57.40

$ echo "1 + (sqrt(3.14 * 32874621) ^2)" | bc
103226210.60

$ echo "1 + (sqrt(3.14 * 18) 255)^" | bc
(standard_in) 1: syntax error

Nesse exemplo, começamos fornecendo uma entrada válida (“1 + (sqrt(3.14 * 18) ^ 2)”) para o programa bc, que computa e retorna o valor calculado (57.40). 

Na linha seguinte, fazemos uma pequena mudança na entrada fornecida (mudamos o tipo e o valor de um elemento da operação), e o programa foi capaz de executar e retornar o resultado. 

Na última linha, no entanto, fizemos uma mudança de operador de potência (^) para o final da operação matemática, o que é sintaticamente inválido. Nesse caso, o programa rejeitou a entrada e encerrou a operação.

Chamamos de semente os valores que são fornecidos como entrada para o programa (ou seeds). Chamamos de mutação o processo de modificação das sementes dos programas. Logo, podemos mutar as semestes que são fornecidas como entrada para um programa qualquer e, assim, podemos gerar novas entradas (potencialmente válidas). 

Poderíamos criar um programa para esse propósito, mas, por praticidade, vamos usar um fuzzer que já implementa essa funcionalidade, chamado Radamsa. Com o Radamsa instalado, podemos rapidamente criar centenas de mutações com base na nossa semente inicial. 

$ echo "1 + (sqrt(3.14 * 18) ^2)" > seed
$ radamsa -o fuzz-%n-seed -n 1000 seed
$ bc fuzz-*

Podemos inclusive usar várias sementes como entrada para criar mutações mais diversas. Algo como: 

$ echo "10 - 1" > seed-1
$ echo "(10 ^2) % 3" > seed-2
$ echo "1 + (sqrt(3.14 * 18) ^2)" > seed-3

$ radamsa -o fuzz-%n-seed -n 1000 seed-*

Com a ideia de sementes e mutação em mente, podemos estendê-las pra outros tipos de entrada. 

Por exemplo, se você consome dados de uma API externa, poderia exercitar seu programa para verificar como ele se comporta com diferentes tipos de documentos JSON. Para isso, basta ter uma semente e fazer algumas mutações nessa semente:

$ echo '{"nome": "Gustavo", "Idade": 35, "carrro": null}' > json-1
$ radamsa -o fuzz-%n-json -n 1000 json-1

Podemos inclusive fazer mutações em programas e fornecê-los como entrada para interpretadores / compiladores. 

$ echo "[1, 2, 3, 4].reduce((a, b) => a + b, 0)" > js-1
$ radamsa -o fuzz-%n-js -n 1000 js-1

No entanto, à medida que vamos avançando na geração de dados mutantes, percebemos que embora as mudanças sejam conservadoras (realizadas em pequenos locais), a quantidade de entradas inválidas não é desprezível. 

Isso acontece pois dados mais estruturados (como documentos XML, JSON ou linguagens de programação) tem uma série de regras bem definidas e nossas mutações podem facilmente inferir alguma dessas regras.

Criando entradas ainda mais inteligentes

A pesquisa em teste de fuzzing tem tido importantes avanços nos últimos anos. Em particular, times de pesquisa têm buscado por técnicas que permitam a geração de entradas mais agressivas, sem necessariamente serem inválidas. Há duas técnicas que merecem destaque: 

  1. A geração de dados com base em uma gramática.
  2. A geração de dados com informação do programa em execução

No primeiro caso, pesquisadores tentam gerar entradas com base em um conjunto de regras bem definidas (ou seja, em uma gramática). A premissa é a seguinte: se soubermos quais são as regras sintáticas dos dados fornecidos como entrada, podemos gerar dados aleatórios que sigam estas regras.

Por exemplo, se um programa recebe uma URL como parâmetro, podemos utilizar a regra gramatical para definição de URLs e, assim, gerar URL que sejam aleatórias porém válidas.

No segundo caso, os pesquisadores colocam instruções dentro do código-fonte do programa em teste. Se uma determinada entrada exercitar aquela instrução, o fuzzer saberá que a entrada fornecida foi válida e, assim, poderá prioriza-la para futuras mutações.

Há ainda pesquisas que utilizam técnicas de machine learning para encontrar melhores formas de gerar fuzzers. A pesquisa em teste fuzzing nunca esteve tão aquecida.

Teste de fuzzing – exemplos na Zup

O time de pesquisa da Zup EDU já usou fuzzers em alguns produtos da Zup. Esses fuzzers, por sua vez, encontraram alguns comportamentos inesperados nesses produtos, que foram reportados para o time de desenvolvimento. 

Para realizar esse teste, usamos uma abordagem bem simples: para cada entrada do programa, fornecemos uma entrada inválida, bem similar àquela gerada pelo primeiro trecho de código em Python deste artigo. Muitas destas entradas foram facilmente tratadas pelos programas, mas algumas não foram capturadas pelos tratadores de erros e exceções como FileNotFound e InvalidURL foram lançadas. 

Mesmo após perceber que uma exceção não foi tratada, é importante identificar qual parte da entrada que passamos exercitou esse comportamento. Lembra que geramos uma entrada aleatória? Precisamos simplificar a vida de devs, precisamos remover manualmente parte da entrada e informar somente a menor parte da entrada que é capaz de exercitar o comportamento observado. 

Antes de reportar, precisamos ainda garantir que o comportamento se repete após sucessivas tentativas da mesma entrada. Após satisfazer essas duas condições, reportamos 10 issues em produtos da Zup, sete dos quais foram confirmadas como bugs e corrigidos pelo time de desenvolvimento.

E o melhor, o custo para criar fuzzers simples como o descrito nesse texto é relativamente pequeno – eu mesmo criei um com poucas linhas de código Python. Portanto, o teste de fuzzing pode aumentar significativamente a qualidade do produto final, sem necessariamente envolver muitas pessoas, ou seja, impactando menos no tempo e nos gastos do projeto.

Conclusão

Embora estratégias de fuzzing sejam eficazes para encontrar defeitos em programas, ele deve ser visto de forma complementar (e não substituta a outras estratégias de teste). 

Para uma estratégia de teste mais efetiva, equipes de desenvolvimento e de teste precisam usar diversas ferramentas e abordagens. Teste de fuzzing é apenas uma destas ferramentas.

E aí, o que achou do teste de fuzzing? Conta para a gente nos comentários suas impressões sobre esse tipo de teste.

Capa do artigo sobre teste de fuzzing, onde uma pessoa segura um smartphone e na sua frente existem um notebook e um monitos. Todos os gadgets apresentam em sua tela conteúdos em linhas de código parecidos.
Foto de Gustavo Pinto
Pesquisador
Uso metodologia científica pra testar as crenças dos times de desenvolvimento. Será que Kotlin é mesmo melhor que Java? Será que multirepos é melhor que monorepos? Será mesmo?

Este site utiliza cookies para proporcionar uma experiência de navegação melhor. Consulte nossa Política de Privacidade.