Pular para o conteúdo principal

Como usar o Echidna para testar contratos inteligentes

Solidity
smart contracts
segurança
testando
fuzzing
Avançado
Trailofbits
10 de abril de 2020
14 minutos de leitura

Instalação

O Echidna pode ser instalado através do docker ou usando o binário pré-compilado.

Echidna através do docker

docker pull trailofbits/eth-security-toolbox
docker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox

O último comando executa o eth-security-toolbox em um docker que tem acesso ao seu diretório atual. Você pode alterar os arquivos do seu host e executar as ferramentas nos arquivos do docker

Dentro do docker, execute:

solc-select 0.5.11
cd /home/training

Binário

https://github.com/crytic/echidna/releases/tag/v1.4.0.0 (opens in a new tab)

Introdução ao fuzzing baseado em propriedade

O Echidna é um fuzzer baseado em propriedade, que descrevemos em nossas postagens de blog anteriores (1 (opens in a new tab), 2 (opens in a new tab), 3 (opens in a new tab)).

Fuzzing

Fuzzing (opens in a new tab) é uma técnica bem conhecida na comunidade de segurança. Consiste em gerar entradas que são mais ou menos aleatórias para encontrar bugs no programa. Fuzzers para software tradicional (como AFL (opens in a new tab) ou LibFuzzer (opens in a new tab)) são conhecidos por serem ferramentas eficientes para encontrar bugs.

Além da geração puramente aleatória de entradas, existem muitas técnicas e estratégias para gerar boas entradas, incluindo:

  • Obter feedback de cada execução e usá-lo para guiar a geração. Por exemplo, se uma entrada recém-gerada levar à descoberta de um novo caminho, pode fazer sentido gerar novas entradas próximas a ela.
  • Gerar a entrada respeitando uma restrição estrutural. Por exemplo, se sua entrada contiver um cabeçalho com um checksum, fará sentido deixar que o fuzzer gere uma entrada que valide o checksum.
  • Usar entradas conhecidas para gerar novas entradas: se você tiver acesso a um grande conjunto de dados de entrada válida, seu fuzzer pode gerar novas entradas a partir deles, em vez de iniciar sua geração do zero. Normalmente, são chamadas de seeds.

Fuzzing baseado em propriedade

O Echidna pertence a uma família específica de fuzzer: fuzzing baseado em propriedade, fortemente inspirado pelo QuickCheck (opens in a new tab). Em contraste com o fuzzer clássico, que tentará encontrar falhas, o Echidna tentará quebrar invariantes definidos pelo usuário.

Em contratos inteligentes, as invariantes são funções do Solidity que podem representar qualquer estado incorreto ou inválido que o contrato possa alcançar, incluindo:

  • Controle de acesso incorreto: o invasor tornou-se o proprietário do contrato.
  • Máquina de estado incorreta: os tokens podem ser transferidos enquanto o contrato está pausado.
  • Aritmética incorreta: o usuário pode causar um underflow em seu saldo e obter tokens gratuitos ilimitados.

Testando uma propriedade com o Echidna

Veremos como testar um contrato inteligente com o Echidna. O alvo é o seguinte contrato inteligente token.sol (opens in a new tab):

1contract Token{
2 mapping(address => uint) public balances;
3 function airdrop() public{
4 balances[msg.sender] = 1000;
5 }
6 function consume() public{
7 require(balances[msg.sender]>0);
8 balances[msg.sender] -= 1;
9 }
10 function backdoor() public{
11 balances[msg.sender] += 1;
12 }
13}
Exibir tudo

Vamos supor que este token deva ter as seguintes propriedades:

  • Qualquer pessoa pode ter no máximo 1.000 tokens
  • O token não pode ser transferido (não é um token ERC20)

Escreva uma propriedade

As propriedades do Echidna são funções do Solidity. Uma propriedade deve:

  • Não ter argumentos
  • Retornar true se for bem-sucedida
  • Ter seu nome começando com echidna

O Echidna irá:

  • Gerar automaticamente transações arbitrárias para testar a propriedade.
  • Relatar quaisquer transações que levem uma propriedade a retornar false ou a lançar um erro.
  • Descartar o efeito colateral ao chamar uma propriedade (ou seja, se a propriedade alterar uma variável de estado, ela será descartada após o teste)

A propriedade a seguir verifica se o chamador não tem mais de 1.000 tokens:

1function echidna_balance_under_1000() public view returns(bool){
2 return balances[msg.sender] <= 1000;
3}

Use herança para separar seu contrato de suas propriedades:

1contract TestToken is Token{
2 function echidna_balance_under_1000() public view returns(bool){
3 return balances[msg.sender] <= 1000;
4 }
5 }

O token.sol (opens in a new tab) implementa a propriedade e herda do token.

Iniciar um contrato

O Echidna precisa de um construtor sem argumentos. Se seu contrato precisar de uma inicialização específica, você precisará fazê-la no construtor.

Existem alguns endereços específicos no Echidna:

  • 0x00a329c0648769A73afAc7F9381E08FB43dBEA72, que chama o construtor.
  • 0x10000, 0x20000 e 0x00a329C0648769a73afAC7F9381e08fb43DBEA70, que chamam aleatoriamente as outras funções.

Não precisamos de nenhuma inicialização específica em nosso exemplo atual, como resultado, nosso construtor está vazio.

Executar o Echidna

O Echidna é iniciado com:

echidna-test contract.sol

Se contract.sol contiver vários contratos, você pode especificar o alvo:

echidna-test contract.sol --contract MyContract

Resumo: testando uma propriedade

O texto a seguir resume a execução do Echidna em nosso exemplo:

1contract TestToken is Token{
2 constructor() public {}
3 function echidna_balance_under_1000() public view returns(bool){
4 return balances[msg.sender] <= 1000;
5 }
6 }
echidna-test testtoken.sol --contract TestToken
...
echidna_balance_under_1000: falhou!💥
Sequência de chamadas, encolhendo (1205/5000):
airdrop()
backdoor()
...
Exibir tudo

O Echidna descobriu que a propriedade é violada se backdoor for chamado.

Filtrando funções para chamar durante uma campanha de fuzzing

Veremos como filtrar as funções que sofrerão fuzzing. O alvo é o seguinte contrato inteligente:

1contract C {
2 bool state1 = false;
3 bool state2 = false;
4 bool state3 = false;
5 bool state4 = false;
6
7 function f(uint x) public {
8 require(x == 12);
9 state1 = true;
10 }
11
12 function g(uint x) public {
13 require(state1);
14 require(x == 8);
15 state2 = true;
16 }
17
18 function h(uint x) public {
19 require(state2);
20 require(x == 42);
21 state3 = true;
22 }
23
24 function i() public {
25 require(state3);
26 state4 = true;
27 }
28
29 function reset1() public {
30 state1 = false;
31 state2 = false;
32 state3 = false;
33 return;
34 }
35
36 function reset2() public {
37 state1 = false;
38 state2 = false;
39 state3 = false;
40 return;
41 }
42
43 function echidna_state4() public returns (bool) {
44 return (!state4);
45 }
46}
Exibir tudo

Este pequeno exemplo força o Echidna a encontrar uma determinada sequência de transações para alterar uma variável de estado. Isso é difícil para um fuzzer (recomenda-se usar uma ferramenta de execução simbólica como o Manticore (opens in a new tab)). Podemos executar o Echidna para verificar isso:

echidna-test multi.sol
...
echidna_state4: passou! 🎉
Seed: -3684648582249875403

Funções de filtragem

O Echidna tem dificuldade em encontrar a sequência correta para testar este contrato porque as duas funções de redefinição (reset1 e reset2) definirão todas as variáveis de estado como false. No entanto, podemos usar um recurso especial do Echidna para adicionar as funções de redefinição à lista de bloqueio ou para adicionar apenas as funções f, g, h e i à lista de permissões.

Para adicionar funções à lista de bloqueio, podemos usar este arquivo de configuração:

1filterBlacklist: true
2filterFunctions: ["reset1", "reset2"]

Outra abordagem para filtrar funções é listar as funções permitidas. Para fazer isso, podemos usar este arquivo de configuração:

1filterBlacklist: false
2filterFunctions: ["f", "g", "h", "i"]
  • filterBlacklist é true por padrão.
  • A filtragem será realizada apenas por nome (sem parâmetros). Se você tiver f() e f(uint256), o filtro "f" corresponderá a ambas as funções.

Executar o Echidna

Para executar o Echidna com um arquivo de configuração blacklist.yaml:

echidna-test multi.sol --config blacklist.yaml
...
echidna_state4: falhou!💥
Sequência de chamadas:
f(12)
g(8)
h(42)
i()

O Echidna encontrará a sequência de transações para falsear a propriedade quase imediatamente.

Resumo: funções de filtragem

O Echidna pode adicionar funções a uma lista de bloqueio ou a uma lista de permissões para chamar durante uma campanha de fuzzing usando:

1filterBlacklist: true
2filterFunctions: ["f1", "f2", "f3"]
echidna-test contract.sol --config config.yaml
...

O Echidna inicia uma campanha de fuzzing adicionando f1, f2 e f3 à lista de bloqueio ou chamando apenas essas funções, de acordo com o valor do booleano filterBlacklist.

Como testar a asserção do Solidity com o Echidna

Neste breve tutorial, vamos mostrar como usar o Echidna para testar a verificação de asserções em contratos. Vamos supor que tenhamos um contrato como este:

1contract Incrementor {
2 uint private counter = 2**200;
3
4 function inc(uint val) public returns (uint){
5 uint tmp = counter;
6 counter += val;
7 // tmp <= counter
8 return (counter - tmp);
9 }
10}
Exibir tudo

Escrever uma asserção

Queremos ter certeza de que tmp é menor ou igual a counter depois de retornar sua diferença. Poderíamos escrever uma propriedade do Echidna, mas precisaríamos armazenar o valor tmp em algum lugar. Em vez disso, poderíamos usar uma asserção como esta:

1contract Incrementor {
2 uint private counter = 2**200;
3
4 function inc(uint val) public returns (uint){
5 uint tmp = counter;
6 counter += val;
7 assert (tmp <= counter);
8 return (counter - tmp);
9 }
10}
Exibir tudo

Executar o Echidna

Para habilitar o teste de falha de asserção, crie um arquivo de configuração do Echidna (opens in a new tab) config.yaml:

1checkAsserts: true

Quando executamos este contrato no Echidna, obtemos os resultados esperados:

echidna-test assert.sol --config config.yaml
Analyzing contract: assert.sol:Incrementor
assertion in inc: failed!💥
Call sequence, shrinking (2596/5000):
inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)
inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)
inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)
Seed: 1806480648350826486
Exibir tudo

Como você pode ver, o Echidna relata alguma falha de asserção na função inc. Adicionar mais de uma asserção por função é possível, mas o Echidna não consegue dizer qual asserção falhou.

Quando e como usar asserções

As asserções podem ser usadas como alternativas a propriedades explícitas, especialmente se as condições a serem verificadas estiverem diretamente relacionadas ao uso correto de alguma operação f. Adicionar asserções após algum código garantirá que a verificação ocorra imediatamente após sua execução:

1function f(..) public {
2 // some complex code
3 ...
4 assert (condition);
5 ...
6}
7

Pelo contrário, o uso de uma propriedade explícita do Echidna executará transações aleatoriamente e não há uma maneira fácil de forçar exatamente quando ela será verificada. Ainda é possível fazer esta solução alternativa:

1function echidna_assert_after_f() public returns (bool) {
2 f(..);
3 return(condition);
4}

No entanto, há alguns problemas:

  • Falha se f for declarada como internal ou external.
  • Não está claro quais argumentos devem ser usados para chamar f.
  • Se f reverter, a propriedade falhará.

Em geral, recomendamos seguir a recomendação de John Regehr (opens in a new tab) sobre como usar asserções:

  • Não force nenhum efeito colateral durante a verificação de asserção. Por exemplo: assert(ChangeStateAndReturn() == 1)
  • Não faça asserções óbvias. Por exemplo, assert(var >= 0) onde var é declarado como uint.

Finalmente, não use require em vez de assert, pois o Echidna não será capaz de detectá-lo (mas o contrato será revertido de qualquer maneira).

Resumo: verificação de asserção

O texto a seguir resume a execução do Echidna em nosso exemplo:

1contract Incrementor {
2 uint private counter = 2**200;
3
4 function inc(uint val) public returns (uint){
5 uint tmp = counter;
6 counter += val;
7 assert (tmp <= counter);
8 return (counter - tmp);
9 }
10}
Exibir tudo
echidna-test assert.sol --config config.yaml
Analyzing contract: assert.sol:Incrementor
assertion in inc: failed!💥
Call sequence, shrinking (2596/5000):
inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)
inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)
inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)
Seed: 1806480648350826486
Exibir tudo

O Echidna descobriu que a asserção em inc pode falhar se essa função for chamada várias vezes com argumentos grandes.

Coletando e modificando um corpus do Echidna

Veremos como coletar e usar um corpus de transações com o Echidna. O alvo é o seguinte contrato inteligente magic.sol (opens in a new tab):

1contract C {
2 bool value_found = false;
3 function magic(uint magic_1, uint magic_2, uint magic_3, uint magic_4) public {
4 require(magic_1 == 42);
5 require(magic_2 == 129);
6 require(magic_3 == magic_4+333);
7 value_found = true;
8 return;
9 }
10
11 function echidna_magic_values() public returns (bool) {
12 return !value_found;
13 }
14
15}
Exibir tudo

Este pequeno exemplo força o Echidna a encontrar determinados valores para alterar uma variável de estado. Isso é difícil para um fuzzer (recomenda-se usar uma ferramenta de execução simbólica como o Manticore (opens in a new tab)). Podemos executar o Echidna para verificar isso:

echidna-test magic.sol
...
echidna_magic_values: passou! 🎉
Seed: 2221503356319272685

No entanto, ainda podemos usar o Echidna para coletar o corpus ao executar esta campanha de fuzzing.

Coletando um corpus

Para habilitar a coleta de corpus, crie um diretório de corpus:

mkdir corpus-magic

E um arquivo de configuração do Echidna (opens in a new tab) config.yaml:

1coverage: true
2corpusDir: "corpus-magic"

Agora podemos executar nossa ferramenta e verificar o corpus coletado:

echidna-test magic.sol --config config.yaml

O Echidna ainda não consegue encontrar os valores mágicos corretos, mas podemos dar uma olhada no corpus que ele coletou. Por exemplo, um desses arquivos era:

1[
2 {
3 "_gas'": "0xffffffff",
4 "_delay": ["0x13647", "0xccf6"],
5 "_src": "00a329c0648769a73afac7f9381e08fb43dbea70",
6 "_dst": "00a329c0648769a73afac7f9381e08fb43dbea72",
7 "_value": "0x0",
8 "_call": {
9 "tag": "SolCall",
10 "contents": [
11 "magic",
12 [
13 {
14 "contents": [
15 256,
16 "93723985220345906694500679277863898678726808528711107336895287282192244575836"
17 ],
18 "tag": "AbiUInt"
19 },
20 {
21 "contents": [256, "334"],
22 "tag": "AbiUInt"
23 },
24 {
25 "contents": [
26 256,
27 "68093943901352437066264791224433559271778087297543421781073458233697135179558"
28 ],
29 "tag": "AbiUInt"
30 },
31 {
32 "tag": "AbiUInt",
33 "contents": [256, "332"]
34 }
35 ]
36 ]
37 },
38 "_gasprice'": "0xa904461f1"
39 }
40]
Exibir tudo

Claramente, essa entrada não acionará a falha em nossa propriedade. No entanto, na próxima etapa, veremos como modificá-lo para isso.

Semeando um corpus

O Echidna precisa de ajuda para lidar com a função magic. Vamos copiar e modificar a entrada para usar parâmetros adequados para ela:

cp corpus/2712688662897926208.txt corpus/new.txt

Modificaremos o new.txt para chamar magic(42,129,333,0). Agora, podemos executar novamente o Echidna:

echidna-test magic.sol --config config.yaml
...
echidna_magic_values: failed!💥
Call sequence:
magic(42,129,333,0)
Unique instructions: 142
Unique codehashes: 1
Seed: -7293830866560616537
Exibir tudo

Desta vez, descobriu que a propriedade é violada imediatamente.

Encontrando transações com alto consumo de gás

Veremos como encontrar as transações com alto consumo de gás com o Echidna. O alvo é o seguinte contrato inteligente:

1contract C {
2 uint state;
3
4 function expensive(uint8 times) internal {
5 for(uint8 i=0; i < times; i++)
6 state = state + i;
7 }
8
9 function f(uint x, uint y, uint8 times) public {
10 if (x == 42 && y == 123)
11 expensive(times);
12 else
13 state = 0;
14 }
15
16 function echidna_test() public returns (bool) {
17 return true;
18 }
19
20}
Exibir tudo

Aqui, expensive pode ter um grande consumo de gás.

Atualmente, o Echidna sempre precisa de uma propriedade para testar: aqui, echidna_test sempre retorna true. Podemos executar o Echidna para verificar isso:

1echidna-test gas.sol
2...
3echidna_test: passou! 🎉
4
5Seed: 2320549945714142710

Medindo o consumo de gás

Para habilitar o consumo de gás com o Echidna, crie um arquivo de configuração config.yaml:

1estimateGas: true

Neste exemplo, também reduziremos o tamanho da sequência de transações para facilitar a compreensão dos resultados:

1seqLen: 2
2estimateGas: true

Executar o Echidna

Depois que o arquivo de configuração for criado, podemos executar o Echidna desta forma:

echidna-test gas.sol --config config.yaml
...
echidna_test: passed! 🎉
f used a maximum of 1333608 gas
Call sequence:
f(42,123,249) Gas price: 0x10d5733f0a Time delay: 0x495e5 Block delay: 0x88b2
Unique instructions: 157
Unique codehashes: 1
Seed: -325611019680165325
Exibir tudo

Filtrando chamadas que reduzem o gás

O tutorial sobre como filtrar funções a serem chamadas durante uma campanha de fuzzing, acima, mostra como remover algumas funções do seu teste.
Isso pode ser crítico para obter uma estimativa de gás precisa. Considere o seguinte exemplo:

1contract C {
2 address [] addrs;
3 function push(address a) public {
4 addrs.push(a);
5 }
6 function pop() public {
7 addrs.pop();
8 }
9 function clear() public{
10 addrs.length = 0;
11 }
12 function check() public{
13 for(uint256 i = 0; i < addrs.length; i++)
14 for(uint256 j = i+1; j < addrs.length; j++)
15 if (addrs[i] == addrs[j])
16 addrs[j] = address(0x0);
17 }
18 function echidna_test() public returns (bool) {
19 return true;
20 }
21}
Exibir tudo

Se o Echidna puder chamar todas as funções, ele não encontrará facilmente transações com alto custo de gás:

1echidna-test pushpop.sol --config config.yaml
2...
3pop used a maximum of 10746 gas
4...
5check used a maximum of 23730 gas
6...
7clear used a maximum of 35916 gas
8...
9push used a maximum of 40839 gas
Exibir tudo

Isso porque o custo depende do tamanho de addrs e chamadas aleatórias tendem a deixar o array quase vazio. Isso ocorre porque o custo depende do tamanho de addrs e as chamadas aleatórias tendem a deixar o array quase vazio. Adicionar pop e clear à lista de bloqueio, no entanto, nos dá resultados muito melhores:

1filterBlacklist: true
2filterFunctions: ["pop", "clear"]
1echidna-test pushpop.sol --config config.yaml
2...
3push used a maximum of 40839 gas
4...
5check used a maximum of 1484472 gas

Resumo: encontrando transações com alto consumo de gás

O Echidna pode encontrar transações com alto consumo de gás usando a opção de configuração estimateGas:

1estimateGas: true
echidna-test contract.sol --config config.yaml
...

O Echidna relatará uma sequência com o consumo máximo de gás para cada função, uma vez que a campanha de fuzzing tenha terminado.

Última atualização da página: 21 de outubro de 2025

Este tutorial foi útil?