O que é teste de fuzz? O que são testes de invariantes? Apresentamos como usar essas ferramentas na Web3 e no Solidity e, explicamos por que elas são essenciais, especialmente para a segurança. Todo projeto deve ter testes de fuzz com estado avançando, e os auditores podem usar invariantes de compreensão para encontrar erros críticos antes que o código seja implantado.
Agradecemos a Trail of Bits e Horsefacts por todo o conteúdo de fuzzing.
Introdução
Na maioria das vezes, os ataques vêm de cenários nos quais você não pensou e para os quais não escreveu um teste.
E se eu dissesse que você poderia escrever um teste que verificaria quase todos os cenários possíveis?
Vamos nos divertir.
Como sempre, você pode assistir ao meu vídeo sobre esse assunto e ver um repositório de amostra completo aqui.
Teste de fuzz com balões
O básico do Fuzzing
O que é um teste de fuzz?
Fuzz Testing ou Fuzzing é quando você fornece dados aleatórios ao seu sistema na tentativa de danificá-lo.
Por exemplo, se este balão for nosso sistema/código, isso envolveria fazer coisas aleatórias no balão para danificá-lo.
Fazendo coisas aleatórias em um balão — exemplo de teste de fuzz
Agora, por que iríamos querer fazer tudo isso?
Vamos dar uma olhada em um exemplo.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MyContract {
uint256 public shouldAlwaysBeZero = 0;
uint256 private hiddenValue = 0;
function doStuff(uint256 data) public {
if (data == 2) {
shouldAlwaysBeZero = 1;
}
if (hiddenValue == 7) {
shouldAlwaysBeZero = 1;
}
hiddenValue = data;
}
}
Digamos que temos essa função chamada doStuff
, que recebe um número inteiro como entrada. Além disso, temos uma variável chamada shouldAlwaysBeZero
que queremos que seja sempre zero.
O fato de que essa variável deve ser sempre zero é conhecido como nossa invariante, ou “propriedade do sistema que deve ser sempre mantida”.
Invariante: A propriedade do sistema que deve sempre ser válida.
Nossa invariante (também conhecida como propriedade) neste contrato é a seguinte:
Invariant: `shouldAlwaysBeZero` DEVE ser sempre 0
Em nosso exemplo de balão, se comercializarmos nosso balão como “indestrutível”, nossa invariante pode ser que “nosso balão nunca deve ser estourado”.
Invariant: `balloon` nunca deve ser estourado
Em DeFi, uma boa invariante pode ser:
- Um protocolo deve sempre ser supercolateralizado.
- Um usuário nunca deve poder sacar mais dinheiro do que depositou.
- Só pode haver 1 ganhador numa loteria justa.
Exemplo no Foundry
Vejamos um teste de unidade normal no Foundry.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {MyContract} from "../src/MyContract.sol";
import {Test} from "forge-std/Test.sol";
contract MyContractTest is Test {
MyContract exampleContract;
function setUp() public {
exampleContract = new MyContract();
}
function testIsAlwaysZeroUnit() public {
uint256 data = 0;
exampleContract.doStuff(data);
assert(exampleContract.shouldAlwaysBeZero() == 0);
}
}
Com este teste de unidade única testIsAlwaysZeroUnit
, podemos pensar que nosso código tem cobertura suficiente, mas se olharmos para a função doStuff
novamente, podemos ver que se nossa entrada for 2, nossa variável não será zero.
function doStuff ( uint256 data ) public {
// O QUE É ESSA DECLARAÇÃO IF???
// 👇👇👇👇👇👇
if (data == 2 ) {
shouldAlwaysBeZero = 1 ;
}
// 👆👆👆👆👆👆
// Ignore isto por enquanto
if (hiddenValue == 7 ) {
shouldAlwaysBeZero = 1 ;
}
hiddenValue = data;
}
Isso parece óbvio com nossa função de exemplo, mas na maioria das vezes, você terá uma função ou sistema parecido com isto:
function hellFunc(uint128 numberr) public view onlyOwner returns (uint256) {
uint256 numberrr = uint256(numberr);
Int number = Int.wrap(numberrr);
if (Int.unwrap(number) == 1) {
if (numbr < 3) {
return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
}
if (Int.unwrap(number) < 3) {
return Int.unwrap((Int.wrap(numbr) - number) * Int.wrap(92) / (number + Int.wrap(3)));
}
if (Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(1)) / Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(numbr)))))))))) == 9) {
return 1654;
}
return 5 - Int.unwrap(number);
}
if (Int.unwrap(number) > 100) {
_numbaar(Int.unwrap(number));
uint256 dog = _numbaar(Int.unwrap(number) + 50);
return (dog + numbr - (numbr / numbir) * numbor) - numbir;
}
if (Int.unwrap(number) > 1) {
if (Int.unwrap(number) < 3) {
return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
}
if (numbr < 3) {
return (2 / Int.unwrap(number)) + 100 - (Int.unwrap(number) * 2);
}
if (Int.unwrap(number) < 12) {
if (Int.unwrap(number) > 6) {
return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
}
}
if (Int.unwrap(number) < 154) {
if (Int.unwrap(number) > 100) {
if (Int.unwrap(number) < 120) {
return (76 / Int.unwrap(number)) + 100 - Int.unwrap(Int.wrap(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(numbr))))))))))))) + Int.wrap(uint256(2)));
}
}
if (Int.unwrap(number) > 95) {
return Int.unwrap(Int.wrap((Int.unwrap(number) % 99)) / Int.wrap(1));
}
if (Int.unwrap(number) > 88) {
return Int.unwrap((Int.wrap((Int.unwrap(number) % 99) + 3)) / Int.wrap(1));
}
if (Int.unwrap(number) > 80) {
return (Int.unwrap(number) + 19) - (numbr * 10);
}
return Int.unwrap(number) + numbr - Int.unwrap(Int.wrap(nunber) / Int.wrap(1));
}
if (Int.unwrap(number) < 7654) {
if (Int.unwrap(number) > 100000) {
if (Int.unwrap(number) < 1200000) {
return (2 / Int.unwrap(number)) + 100 - (Int.unwrap(number) * 2);
}
}
if (Int.unwrap(number) > 200) {
if (Int.unwrap(number) < 300) {
return (2 / Int.unwrap(number)) + Int.unwrap(Int.wrap(100) / (number + Int.wrap(2)));
}
}
}
}
if (Int.unwrap(number) == 0) {
if (Int.unwrap(number) < 3) {
return Int.unwrap((Int.wrap(2) - (number * Int.wrap(2))) * Int.wrap(100) / (Int.wrap(Int.unwrap(number)) + Int.wrap(2)));
}
if (numbr < 3) {
return (Int.unwrap(Int.wrap(2) - (number * Int.wrap(3)))) + 100 - (Int.unwrap(number) * 2);
}
if (numbr == 10) {
return Int.unwrap(Int.wrap(10));
}
return (236 * 24) / Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(number)))))));
}
return numbr + nunber - mumber - mumber;
}
Este foi um dos desafios de segurança do Cyfrin.
Aqui, não é tão óbvio se houver uma entrada que causará uma reversão. Seria insano escrever um caso de teste para cada inteiro ou cenário possível, então precisamos de uma maneira programática de encontrar qualquer anomalia.
Existem duas metodologias populares para localizar esses casos extremos de forma programática:
- Fuzz / Testes de Invariantes.
- Verificação Formal / Execução Simbólica
Manteremos a “Verificação formal” para outro vídeo.
No Foundry, você escreveria um teste de fuzz no Solidity assim:
function testIsAlwaysZeroFuzz(uint256 randomData) public {
// uint256 data = 0; // linha comentada
exampleContract.doStuff(randomData);
assert(exampleContract.shouldAlwaysBeZero() == 0);
}
O Foundry irá inserir automaticamente valores semi-aleatórios em randomData
e, ao longo de um número x de execuções, inserirá esses valores em doStuff
e verificará se a asserção é válida.
Isso seria equivalente a escrever muitos testes em que randomData
tivesse valores diferentes, tudo em um só teste!
Agora eu digo “semi-aleatório” porque a maneira como seu fuzzer (no nosso caso, o Foundry) escolhe os dados aleatórios não é realmente aleatório e deve ser um tanto inteligente com os números aleatórios que ele escolhe. O Foundry é inteligente o suficiente para ver a condicional if data == 2
e escolher 2
como uma entrada.
Equidna — O logotipo real
No momento, acho que a trilha de bits Equidna híbrida é o melhor fuzzer existente devido à sua seleção inteligente de números aleatórios, mas o fuzzer do Foundry (na minha opinião) é mais fácil de escrever código no momento. O logotipo da Equidna também é o melhor logotipo que já vi. Ainda melhor do que o logotipo do ripped Jesus.
De qualquer forma, se executarmos nosso teste de fuzz, ele nos dirá exatamente qual entrada falhou em nosso teste:
$ forge test -m testIsAlwaysZeroFuzz
Failing tests:
Encountered 1 failing test in test/MyContractTest.t.sol:MyContractTest
[FAIL. Reason: Assertion violated Counterexample: calldata=0x47fb53d00000000000000000000000000000000000000000000000000000000000000002, args=[2]] testIsAlwaysZeroFuzz(uint256) (runs: 6, μ: 27070, ~: 30387)
Podemos ver que ele descobriu que, se passasse args=[2]
para o teste, conseguiria danificar nosso assert(exampleContract.shouldAlwaysBeZero() == 0)
. Portanto, agora podemos voltar ao nosso código e perceber que precisamos corrigir o caso extremo em que data == 2
, e agora estamos protegidos contra a exploração de dados de entrada sendo 2!
Resumo das noções básicas do Fuzzing
Em resumo, para escrever um teste de fuzz, fizemos o seguinte
- Entendemos nossa invariante ou “propriedade de nosso sistema que deve ser sempre mantida”.
- Escrevemos um teste que vai inserir valores aleatórios em nossa função para tentar danificar nossa invariante.
Fuzzing com estado vs. sem estado
Fuzzing sem estado
Agora você pode perceber que há outro cenário em que nosso código pode ter um problema, e é quando hiddenValue == 7
. Para que essa reversão aconteça, você deve primeiro definir hiddenValue
como 7
, chamando doStuff
com o valor 7 que define hiddenValue
como 7 e, em seguida, chamar essa função novamente.
uint256 hiddenValue = 0 ;
function doStuff ( uint256 data ) public {
// corrigiu esta parte removendo-a
// se (data == 2) {
// shouldAlwaysBeZero = 1;
// }
// Espera o que é isso??
// 👇👇👇👇👇👇👇
if (hiddenValue == 7 ) {
shouldAlwaysBeZero = 1 ;
}
// 👆👆👆👆👆👆👆
hiddenValue = data;
}
São necessárias 2 chamadas para que nossa invariante seja danificada.
- Chamar
doStuff
com7.
- Chamar
doStuff
com qualquer outro número.
Nosso teste de fuzz escrito acima nunca será capaz de encontrar este exemplo porque, como está escrito atualmente, nosso teste é conhecido como "teste de fuzz sem estado". Que é onde o estado de uma execução anterior é descartado para a próxima execução.
Fuzzing sem estado: Teste de Fuzzing/Fuzz em que o estado de uma execução anterior é descartado para a próxima execução.
Um exemplo de 2 execuções de fuzz sem estado
Se voltarmos ao exemplo do balão, o fuzzing sem estado seria semelhante a fazer algo no balão A para uma tentativa aleatória de danificá-lo e, em seguida, explodir um novo balão B e tentar danificá-lo de maneira diferente.
No exemplo do balão, você nunca tentaria danificar um balão que já tentou danificar no passado. Isso parece um pouco bobo, pois se a invariante do nosso balão for “o balão não pode ser estourado”, gostaríamos de fazer várias tentativas no mesmo balão.
Fuzzing com estado
Portanto, na engenharia de software, poderíamos fazer “fuzzing com estado”. Fuzzing com estado é quando o estado de nossa execução anterior é o estado inicial de nossa próxima execução.
Fuzzing com estado: O estado da execução anterior do fuzzing é o estado inicial da próxima execução do fuzz.
Um exemplo de 1 execução de fuzz com estado, em que várias tentativas são feitas em uma execução
Uma única execução de fuzz com informações de estado seria semelhante a escrever um teste com todos os seus recursos no mesmo teste.
function testIsAlwaysZeroUnitManyCalls() public {
uint256 data = 7;
exampleContract.doStuff(data);
assert(exampleContract.shouldAlwaysBeZero() == 0);
data = 0;
exampleContract.doStuff(data);
assert(exampleContract.shouldAlwaysBeZero() == 0); // isso falharia
}
A propósito, essa é uma prática ruim; não escreva suas afirmações no mesmo teste, por favor.
Para escrever um teste de fuzz com estado no Foundry, você usaria a palavra-chave invariant e isso requer um pouco mais de configuração.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import {MyContract} from "../src/MyContract.sol";
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";
contract MyContractTest is StdInvariant, Test {
MyContract exampleContract;
function setUp() public {
exampleContract = new MyContract();
targetContract(address(exampleContract));
}
function invariant_testAlwaysReturnsZero() public {
assert(exampleContract.shouldAlwaysBeZero() == 0);
}
}
Em vez de apenas passar dados aleatórios para as chamadas de função, um teste de fuzz com estado (invariante) chamará automaticamente funções aleatórias com dados aleatórios.
Usamos a função targetContract
para dizer ao Foundry que ele pode usar qualquer uma das funções em exampleContract
. Existe apenas uma função para este exemplo, então ela chamará apenas doStuff
com valores diferentes.
Se executarmos este teste, poderemos ver a saída como tal e veremos que ele descobre que, se você chamar doStuff
duas vezes (uma vez com o valor 7), ele lançará um erro!
$ forge test -m invariant_testAlwaysReturnsZero
Failing tests:
Encountered 1 failing test in test/MyContractTest.t.sol:MyContractTest
[FAIL. Reason: Assertion violated]
[Sequence]
sender=0x000000000000000000000000000000000000018f addr=[src/MyContract.sol:MyContract]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=doStuff(uint256), args=[7]
sender=0x0000000008ba49893f3f5ba10c99ef3a4209b646 addr=[src/MyContract.sol:MyContract]0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f calldata=doStuff(uint256), args=[2390]
Espere, o que é uma invariante mesmo?
Agora, uma observação importante sobre como o Foundry usa o termo invariante. Conforme descrevemos, uma invariante é uma propriedade do sistema que deve sempre ser mantida, mas o Foundry usa o termo para se referir a “fuzzing com estado”. Tenha isso em mente.
- Testes de Invariantes do Foundry == Fuzzing com estado.
- Testes de Fuzz do Foundry == Fuzzing sem estado.
- Invariantes == Propriedade do sistema que deve ser sempre mantida.
Portanto, em um contrato inteligente real, sua invariante não será que um balão não deva estourar ou alguma função deva ser sempre zero; será algo como:
- Novos tokens cunhados < taxa de inflação.
- Deve haver apenas 1 ganhador de uma loteria aleatória.
- Alguém não deve ser capaz de retirar mais dinheiro do protocolo do que colocou.
E deixe-me dizer uma coisa, a esta altura, você já conhece todos os fundamentos do Fuzzing! Parabéns! Talvez agora você faça uma pausa e tente escrever alguns testes você mesmo e depois volte para o vídeo.
Este é o novo mínimo
Este é o novo piso de segurança na Web3. É sistemático, qualquer um pode aprender, e fazê-lo e pode evitar MUITA dor de cabeça.
- Entenda seus invariantes.
- Escreva testes de fuzz com estado para eles.
- Não vá a uma auditoria antes.
- Se for, certifique-se de que os auditores o ajudem a entender suas invariantes!
Agora que você entende os fundamentos dos testes de fuzzing e invariantes, pode usar as ferramentas que quiser! Para saber mais sobre testes avançados de fuzz com estado, fique atento, pois teremos um vídeo avançado em breve! Além disso, leia a documentação do Foundry sobre o método Handler, pois essa é a maneira recomendada de criar os testes de fuzz com estado mais sofisticados.
Este artigo do Horsefacts também faz um passo a passo incrível.
Divirta-se!
Siga o Patrick!
Agende uma auditoria de contrato inteligente: Cyfrin
Uploads e Shorts do Twitch Stream
Artigo escrito por Patrick Collins. Traduzido por Marcelo Panegali.
Oldest comments (0)