Como usar o Echidna para testar contratos inteligentes
Instalação
Echidna pode ser instalado através do docker ou usando o binário pré-compilado.
Echidna com docker
docker pull trailofbits/eth-security-toolboxdocker run -it -v "$PWD":/home/training trailofbits/eth-security-toolboxO último comando roda a 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 através do docker
Dentro do docker, execute :
solc-select 0.5.11cd /home/trainingBinário
https://github.com/crytic/echidna/releases/tag/v1.4.0.0
Introdução a fuzzing baseado em propriedade
Echidna é um fuzzer baseado em propriedades, descrevemos em nossos posts anteriores (1, 2, 3).
Fuzzing
Fuzzing é uma técnica bem conhecida na comunidade de segurança. It consists of generating inputs that are more or less random to find bugs in the program. Fuzzers por software tradicional (como AFL ou LibFuzzer) são conhecidos por serem ferramentas eficientes para encontrar bugs.
Além da geração aleatória de entradas, há muitas técnicas e estratégias para gerar bons inputs, incluindo:
- Obtenha feedback de cada execução e geração de guias usando-o. Por exemplo, se uma entrada recém-gerada leva à descoberta de um novo caminho, ele pode fazer sentido para gerar novas entradas fechadas a ele.
- Geração da entrada respeitando uma restrição estrutural. Por exemplo, se a sua entrada contiver um cabeçalho com uma soma de verificação, fará sentido deixar o difusor gerar uma entrada validando a soma de verificação.
- Usando entradas conhecidas para gerar novas entradas: se você tem acesso a um grande conjunto de dados de entrada válida, seu difusor pode gerar novas entradas a partir deles, ao invés de começar sua geração do zero. Eles geralmente são chamados de seeds.
Fuzzing baseado em propriedade
Echidna pertence a uma família específica de fuzzer: fuzzing baseada em propriedades fortemente inspirada pelo QuickCheck. Em contraste com o fuzzing clássico que tentará encontrar falhas, Echidna tentará quebrar invariantes definidos pelo usuário.
Nos contratos inteligentes, invariantes são funções Solidity, que podem representar qualquer estado incorreto ou inválido que o contrato possa alcançar, incluindo:
- Controle de acesso incorreto: quem ataca tornou-se o proprietário do contrato.
- Máquina de estado incorreta: os tokens podem ser transferidos enquanto o contrato é pausado.
- Aritmética incorreta: o usuário pode passar abaixo do saldo e obter tokens gratuitos ilimitados.
Testando uma propriedade com Echidna
Veremos como testar um contrato inteligente com o Echidna. O alvo é o seguinte contrato inteligente exemplo.sol:
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 tudoAssumiremos que esse token deve ter as seguintes propriedades:
- Qualquer um pode ter no máximo 1000 tokens
- O token não pode ser transferido (não é um token ERC20)
Escrever uma propriedade
Propriedades do Echidna são funções de Solidity. Uma propriedade deve:
- Ter nenhum argumento
- Retornar
verdadeirose for bem sucedido - Tenha seu nome começando com
echidna
Echidna irá:
- Gera automaticamente transações arbitrárias para testar a propriedade.
- Relata quaisquer transações que levem uma propriedade para retornar `` falso ou lançar um erro.
- Descartar efeito lateral ao chamar uma propriedade (ou seja, se a propriedade altera uma variável de estado, ela é descartada após o teste)
A propriedade a seguir verifica que o "caller" não possui mais do que 1000 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 }token.sol implementa a propriedade e herda do token.
Iniciar um contrato
Echidna precisa de um constructor sem argumento. Se seu contrato precisa de uma inicialização específica, você precisa fazê-lo no construtor.
Há alguns endereços específicos no Echidna:
0x00a329c0648769A73afAc7F9381E08FB43dBEA72que chama o constructor.0x10000,0x20000, e0x00a329C0648769a73afAC7F9381e08fb43DBEA70que aleatoriamente chama as outras funções.
Nós não precisamos de nenhuma inicialização específica em nosso exemplo atual, como resultado, nosso construtor está vazio.
Executando Echidna
Echidna foi lançado com:
echidna-test contract.solSe o contract.sol contém múltiplos contratos, você pode especificar o alvo:
echidna-test contract.sol --contract MyContractResumo: Testando uma propriedade
O seguinte resumo é a execução de Echidna no 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: failed!💥 Call sequence, shrinking (1205/5000): airdrop() backdoor()...Exibir tudoEchidna descobriu que a propriedade é violada se backdoor é chamada.
Filtrando funções para chamar durante uma campanha de fuzzing
Veremos como filtrar as funções a serem "fuzzed". O alvo é o seguinte contrato inteligente:
1contract C {2 bool state1 = false;3 bool state2 = false;4 bool state3 = false;5 bool state4 = false;67 function f(uint x) public {8 require(x == 12);9 state1 = true;10 }1112 function g(uint x) public {13 require(state1);14 require(x == 8);15 state2 = true;16 }1718 function h(uint x) public {19 require(state2);20 require(x == 42);21 state3 = true;22 }2324 function i() public {25 require(state3);26 state4 = true;27 }2829 function reset1() public {30 state1 = false;31 state2 = false;32 state3 = false;33 return;34 }3536 function reset2() public {37 state1 = false;38 state2 = false;39 state3 = false;40 return;41 }4243 function echidna_state4() public returns (bool) {44 return (!state4);45 }46}Exibir tudoEste pequeno exemplo força Echidna a encontrar uma determinada sequência de transações para alterar uma variável de estado. Isso é difícil para um fuzzer (é recomendado usar uma ferramenta de execução simbólica como Manticore). Podemos executar o Echidna para verificar isto:
echidna-test multi.sol...echidna_state4: passed! 🎉Seed: -3684648582249875403Filtrando funções
Echidna tem problemas para encontrar a sequência correta para testar esse 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 Echidna para ou para a lista negra redefinir a função ou apenas para a lista branca f, g, h e i funções.
Para funções da lista negra, podemos usar esse arquivo de configuração:
1filterBlacklist: true2filterFunctions: ["reset1", "reset2"]Outra abordagem para as funções de filtro é listar as funções na lista branca. Para fazer isso, podemos usar este arquivo de configuração:
1filterBlacklist: false2filterFunctions: ["f", "g", "h", "i"]filterBlacklistéverdadeiropor padrão.- A filtragem será executada apenas por nome (sem parâmetros). Se você tiver
f()ef(uint256), o filtro"f"corresponderá a ambas as funções.
Executar Echidna
Para executar Echidna com um arquivo de configuração blacklist.yaml:
echidna-test multi.sol --config blacklist.yaml...echidna_state4: failed!💥 Call sequence: f(12) g(8) h(42) i()Echidna vai encontrar a sequência de transações para falsificar a propriedade quase de forma mesquinha.
Resumo: Filtrando funções
Echidna pode ser chamada na lista negra ou na lista branca durante uma campanha de fuzzing:
1filterBlacklist: true2filterFunctions: ["f1", "f2", "f3"]echidna-test contract.sol --config config.yaml...Echidna inicia uma campanha de fuzzing em qualquer blacklist f1, f2 e f3 ou apenas chamando a eles, de acordo com o valor do booleano filterBlacklist.
Como testar a asserção de Solidity com Echidna
Neste breve tutorial, vamos mostrar como usar o Echidna para testar a verificação de asserção em contratos. Vamos supor que tenhamos um contrato como este:
1contract Incrementor {2 uint private counter = 2**200;34 function inc(uint val) public returns (uint){5 uint tmp = counter;6 counter += val;7 // tmp <= counter8 return (counter - tmp);9 }10}Exibir tudoEscreva uma asserção
Queremos ter certeza de que tmp é menor ou igual a contador depois de retornar a sua diferença. Nós poderíamos escrever uma propriedade de Echidna, mas precisaremos armazenar o valor de tmp em algum lugar. Em vez disso, poderíamos usar uma asserção como esta:
1contract Incrementor {2 uint private counter = 2**200;34 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 tudoExecutando Echidna
Para habilitar o teste de falha de asserção, crie um arquivo de configuração Echidna config.yaml:
1checkAsserts: trueQuando executamos este contrato em Echidna, obtemos os resultados esperados:
echidna-test assert.sol --config config.yamlAnalyzing contract: assert.sol:Incrementorassertion in inc: failed!💥 Call sequence, shrinking (2596/5000): inc(21711016731996786641919559689128982722488122124807605757398297001483711807488) inc(7237005577332262213973186563042994240829374041602535252466099000494570602496) inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)Seed: 1806480648350826486Exibir tudoComo você pode ver, Echidna relata algumas falhas de afirmação na função inc. Adicionar mais de uma asserção por função é possível, mas Echidna não pode dizer qual afirmação falhou.
Quando e como usar asserções
As asserções podem ser usadas como alternativas às propriedades explícitas, se as condições a serem verificadas estão diretamente relacionadas com o uso correto de alguma operação f. Adicionar asserções após algum código forçará que a verificação ocorra imediatamente após sua execução:
1function f(..) public {2 // some complex code3 ...4 assert (condition);5 ...6}7Pelo contrário, usando uma propriedade Echidna explícita irá executar transações aleatoriamente e não há maneira fácil de aplicar exatamente quando elas serão verificadas. Ainda é possível fazer esta solução alternativa:
1function echidna_assert_after_f() public returns (bool) {2 f(..);3 return(condition);4}Entretanto, existem alguns problemas: %{issues}:
- Ele falha se
fé declarado comointernoouexterno. - Não está claro quais argumentos devem ser usados para chamar
f. - Se
freverter, a propriedade irá falhar.
Em geral, recomendamos seguir a recomendação de John Regehr sobre como usar asserções:
- Não force qualquer 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)ondevaré declarado comouint.
Finalmente, não use require em vez de assert, já que Echidna não será capaz de detectá-lo (mas o contrato será revertido mesmo assim).
Resumo: checando a asserção
O seguinte resumo é a execução de Echidna no nosso exemplo:
1contract Incrementor {2 uint private counter = 2**200;34 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 tudoechidna-test assert.sol --config config.yamlAnalyzing contract: assert.sol:Incrementorassertion in inc: failed!💥 Call sequence, shrinking (2596/5000): inc(21711016731996786641919559689128982722488122124807605757398297001483711807488) inc(7237005577332262213973186563042994240829374041602535252466099000494570602496) inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)Seed: 1806480648350826486Exibir tudoEchidna percebeu que a asserção em inc pode falhar se essa função é chamada várias vezes com argumentos grandes.
Coletando e modificando um corpus Echidna
Veremos como coletar e usar um corpus de transações com Echidna. O alvo é o seguinte contrato inteligente exemplo.sol:
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 }1011 function echidna_magic_values() public returns (bool) {12 return !value_found;13 }1415}Exibir tudoEste pequeno exemplo força Echidna a encontrar uma determinada sequência de transações para alterar uma variável de estado. Isso é difícil para um fuzzer (é recomendado usar uma ferramenta de execução simbólica como Manticore). Podemos executar o Echidna para verificar isto:
echidna-test magic.sol...echidna_magic_values: passed! 🎉Seed: 2221503356319272685No entanto, ainda podemos usar o Echidna para coletar corpus na condução desta campanha de fuzzing.
Coletando um corpus
Para habilitar a coleção de corpus, crie um diretório corpus:
mkdir corpus-magicE um arquivo de configuração Echidna config.yaml:
1coverage: true2corpusDir: "corpus-magic"Agora podemos rodar nossa ferramenta e checar o corpus coletado:
echidna-test magic.sol --config config.yamlEchidna ainda não conseguiu encontrar os valores mágicos corretos, mas podemos olhar para o corpus que ele coletou. Por exemplo, um desses arquivos foi:
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 tudoClaramente, esse input não causará falha em nossa propriedade. No entanto, no próximo passo, veremos como modificá-lo nesse sentido.
Semeando um corpus
Echidna precisa de ajuda para lidar com a função mágica. Vamos copiar e modificar a entrada para usar os parâmetros adequados para ele:
cp corpus/2712688662897926208.txt corpus/new.txtNós iremos modificar new.txt para chamar mágica(42,129,333,0). Agora, podemos reexecutar o Echidna:
echidna-test magic.sol --config config.yaml...echidna_magic_values: failed!💥 Call sequence: magic(42,129,333,0)Unique instructions: 142Unique codehashes: 1Seed: -7293830866560616537Exibir tudoDesta vez, constatou que a propriedade é violada imediatamente.
Localizando transações com alto consumo de gas
Veremos como encontrar as transações com alto consumo de gas com o Echidna. O alvo é o seguinte contrato inteligente:
1contract C {2 uint state;34 function expensive(uint8 times) internal {5 for(uint8 i=0; i < times; i++)6 state = state + i;7 }89 function f(uint x, uint y, uint8 times) public {10 if (x == 42 && y == 123)11 expensive(times);12 else13 state = 0;14 }1516 function echidna_test() public returns (bool) {17 return true;18 }1920}Exibir tudoAqui caro pode ter um grande consumo de gas.
Atualmente, Echidna sempre precisa de uma propriedade para testar: aqui echidna_test sempre retorna true. Podemos executar o Echidna para verificar isto:
1echidna-test gas.sol2...3echidna_test: passed! 🎉45Seed: 2320549945714142710Medição do consumo de gas
Para habilitar o consumo de gas com Echidna, crie um arquivo de configuração config.yaml:
1estimateGas: trueNeste exemplo, também reduziremos o tamanho da sequência de transações para facilitar a compreensão dos resultados:
1seqLen: 22estimateGas: trueExecutando Echidna
Assim que tivermos o arquivo de configuração criado, poderemos executar o Echidna assim:
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: 0x88b2Unique instructions: 157Unique codehashes: 1Seed: -325611019680165325Exibir tudo- O gas mostrado é um cálculo fornecido por HEVM.
Filtrando Chamadas com Redução de Gas
O tutorial sobre funções de filtragem para chamar durante uma campanha de difusão acima mostra como remover algumas funções de seu teste.
Isso pode ser fundamental para obter uma estimativa de gas 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 tudoSe Echidna pode chamar todas as funções, ele não encontrará facilmente transações com alto custo de gas:
1echidna-test pushpop.sol --config config.yaml2...3pop used a maximum of 10746 gas4...5check used a maximum of 23730 gas6...7clear used a maximum of 35916 gas8...9push used a maximum of 40839 gasExibir tudoIsso porque o custo depende do tamanho dos addrs e chamadas aleatórias tendem a deixar o array quase vazio. Lista negra pop e limpa, no entanto, nos dá resultados muito melhores:
1filterBlacklist: true2filterFunctions: ["pop", "clear"]1echidna-test pushpop.sol --config config.yaml2...3push used a maximum of 40839 gas4...5check used a maximum of 1484472 gasLocalizando transações com alto consumo de gás
Echidna pode encontrar transações com alto consumo de gás usando a opção de configuração estimateGas:
1estimateGas: trueechidna-test contract.sol --config config.yaml...Echidna irá relatar uma sequência com o consumo máximo de gas para cada função, uma vez terminada a campanha de fuzzing.