Come usare Echidna per testare gli smart contract
Installazione
Echidna è installabile tramite docker o usando il binario pre-compilato.
Echidna tramite docker
docker pull trailofbits/eth-security-toolboxdocker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox
L'ultimo comando esegue eth-security-toolbox in un docker che ha accesso alla tua cartella corrente. Puoi modificare i file dal tuo host ed eseguire gli strumenti sui file dal docker
Nel docker, esegui:
solc-select 0.5.11cd /home/training
Binario
https://github.com/crytic/echidna/releases/tag/v1.4.0.0(opens in a new tab)
Introduzione al fuzzing basato sulla proprietà
Echidna è un fuzzer basato sulla proprietà, che abbiamo descritto nei nostri post del blog precedenti (1(opens in a new tab), 2(opens in a new tab), 3(opens in a new tab)).
Fuzzing
Il fuzzing(opens in a new tab) è una ben nota tecnica nella community della sicurezza. Consiste nella generazione di input più o meno randomici, per trovare i bug nel programma. I fuzzer per il software tradizionale (come AFL(opens in a new tab) o LibFuzzer(opens in a new tab)) sono noti per essere strumenti efficienti per trovare i bug.
Oltre alla generazione di input puramente casuale, esistono molte tecniche e strategie per generare input validi, tra cui:
- Ottenere feedback da ogni esecuzione e generazione della guida durante l'utilizzo. Ad esempio, se un input appena generato conduce alla scoperta di un nuovo percorso, può avere senso generare nuovi input vicini a esso.
- Generare l'input rispettando un vincolo strutturale. Ad esempio, se il tuo input contiene un'intestazione con un checksum, avrà senso lasciare che il fuzzer generi input a convalida del checksum.
- Usare input noti per generarne di nuovi: se hai accesso a un grande dataset di input validi, il tuo fuzzer può generarne di nuovi, anziché iniziare da zero la generazione. In genere sono detti seed.
Fuzzing basato sulla proprietà
Echidna appartiene a una famiglia specifica di fuzzer: il fuzzing basato sulla proprietà, ampiamente ispirato da QuickCheck(opens in a new tab). In contrasto al fuzzer classico che prova a trovare i crash, Echidna prova a rompere le invarianti definite dall'utente.
Negli smart contract, le invarianti sono funzioni di Solidity, che possono rappresentare ogni stato non corretto o non valido raggiungibile dal contratto, tra cui:
- Controllo d'accesso errato: il malintenzionato diventa il proprietario del contratto.
- Macchina di stato errata: i token sono trasferibili mentre il contratto è in pausa.
- Aritmetica errata: l'utente può sottovalutare il proprio saldo e ottenere token gratuiti illimitati.
Testare una proprietà con Echidna
Vedremo come testare un contratto intelligente con Echidna. L'obiettivo è il seguente smart contract 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}Mostra tuttoCopia
Presumeremo che questo token debba avere le seguenti proprietà:
- Chiunque può avere al massimo 1.000 token
- Il token non è trasferibile (non è un token ERC20)
Scrivi una proprietà
Le proprietà di Echidna sono funzioni di Solidity. Una proprietà deve:
- Non avere alcun argomento
- Restituire
true
se va a buon fine - Avere un nome che inizia per
echidna
Echidna:
- Genererà automaticamente transazioni arbitrarie per testare la proprietà.
- Segnalare ogni transazione che fa sì che una proprietà restituisca
false
o generi un errore. - Scartare l'effetto collaterale quando si chiama una proprietà (ad es. se la proprietà cambia una variabile di stato, viene scartata dopo il test)
La seguente proprietà controlla che il chiamante non abbia più di 1.000 token:
1function echidna_balance_under_1000() public view returns(bool){2 return balances[msg.sender] <= 1000;3}Copia
Usa l'eredità per separare il contratto dalle proprietà:
1contract TestToken is Token{2 function echidna_balance_under_1000() public view returns(bool){3 return balances[msg.sender] <= 1000;4 }5 }Copia
token.sol
(opens in a new tab) implementa la proprietà ed eredita dal token.
Avviare un contratto
Echidna necessita di un costruttore senza argomento. Se il tuo contratto necessita di un'inizializzazione specifica, devi farlo nel costruttore.
Esistono degli indirizzi specifici in Echidna:
0x00a329c0648769A73afAc7F9381E08FB43dBEA72
per chiamare il costruttore.0x10000
,0x20000
, e0x00a329C0648769a73afAC7F9381e08fb43DBEA70
per chiamare casualmente le altre funzioni.
Non necessitiamo di alcuna inizializzazione particolare nel nostro esempio corrente, di conseguenza il nostro costruttore è vuoto.
Eseguire Echidna
Echidna è avviato con:
echidna-test contract.sol
Se contract.sol contiene più contratti, puoi specificare l'obiettivo:
echidna-test contract.sol --contract MyContract
Riepilogo: Testare una proprietà
Di seguito è riepilogata l'esecuzione di Echidna nel nostro esempio:
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 }Copia
echidna-test testtoken.sol --contract TestToken...echidna_balance_under_1000: failed!💥Call sequence, shrinking (1205/5000):airdrop()backdoor()...Mostra tutto
Echidna ha individuato che la proprietà è violata se backdoor
viene chiamata.
Filtrare le funzioni da chiamare durante una campagna di fuzzing
Vedremo come filtrare le funzioni da sottoporre a fuzzing. L'obiettivo è il seguente smart contract:
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}Mostra tuttoCopia
Questo piccolo esempio forza Echidna a trovare una certa sequenza di transazioni per modificare una variabile di stato. Ciò è difficile per un fuzzer (si consiglia di usare uno strumento di esecuzione simbolica come Manticore(opens in a new tab)). Possiamo eseguire Echidna per verificarlo:
echidna-test multi.sol...echidna_state4: passed! 🎉Seed: -3684648582249875403
Funzioni di filtraggio
Echidna fa fatica a trovare la sequenza corretta per testare questo contratto perché le due funzioni di reset (reset1
e reset2
) imposteranno tutte le variabili di stato su false
. Tuttavia, possiamo usare una funzionalità speciale di Echidna per inserire nella blacklist la funzione di reset o nella whitelist solo le funzioni f
, g
, h
e i
.
Per inserire le funzioni nella blacklist, possiamo usare questo file di configurazione:
1filterBlacklist: true2filterFunctions: ["reset1", "reset2"]
Un altro approccio per filtrare le funzioni è elencare quelle nella whitelist. Per farlo, possiamo usare questo file di configurazione:
1filterBlacklist: false2filterFunctions: ["f", "g", "h", "i"]
filterBlacklist
ètrue
di default.- Il filtraggio sarà eseguito solo per nome (senza parametri). Se hai
f()
ef(uint256)
, il filtro"f"
abbinerà entrambe le funzioni.
Eseguire Echidna
Per eseguire Echidna con un file di configurazione blacklist.yaml
:
echidna-test multi.sol --config blacklist.yaml...echidna_state4: failed!Call sequence:f(12)g(8)h(42)i()
Echidna troverà la sequenza di transazioni per falsificare la proprietà quasi immediatamente.
Riepilogo: Filtrare le funzioni
Echidna può inserire le funzioni di blacklist o whitelist da chiamare durante una campagna di fuzzing usando:
1filterBlacklist: true2filterFunctions: ["f1", "f2", "f3"]
echidna-test contract.sol --config config.yaml...
Echidna avvia una campagna di fuzzing inserendo f1
, f2
e f3
nella blacklist o solo chiamandole, in base al valore del booleano filterBlacklist
.
Come testare l'affermazione di Solidity con Echidna
In questo breve tutorial, mostreremo come usare Echidna per testare il controllo dell'affermazione nei contratti. Supponiamo di avere un contratto come questo:
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}Mostra tuttoCopia
Scrivere un'asserzione
Vogliamo assicurarci che tmp
sia minore o uguale al counter
dopo averne restituita la differenza. Potremmo scrivere una proprietà di Echidna, ma dovremmo memorizzare da qualche parte il valore tmp
. Invece, potremmo usare un'asserzione come questa:
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}Mostra tuttoCopia
Eseguire Echidna
Per abilitare il test di insuccesso dell'asserzione, crea un file di configurazione di Echidna(opens in a new tab) config.yaml
:
1checkAsserts: true
Quando eseguiamo questo contratto in Echidna, otteniamo i risultati previsti:
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: 1806480648350826486Mostra tutto
Come puoi vedere, Echidna segnala alcuni insuccessi dell'asserzione nella funzione inc
. Aggiungere più di un'asserzione per funzione è possibile, ma Echidna non può dire quale sia fallita.
Quando e come usare le asserzioni
Le asserzioni sono utilizzabili come alternative a proprietà esplicite, specialmente se le condizioni per il controllo sono direttamente correlate all'uso corretto della stessa operazione f
. Aggiungere le asserzioni dopo il codice farà sì che il controllo abbia luogo immediatamente dopo la sua esecuzione:
1function f(..) public {2 // some complex code3 ...4 assert (condition);5 ...6}7Copia
Al contrario, l'utilizzo di una proprietà esplicita di Echidna eseguirà casualmente le transazioni e non esiste alcun metodo semplice per imporre esattamente quando verrà controllata. È comunque possibile sfruttare questa scappatoia:
1function echidna_assert_after_f() public returns (bool) {2 f(..);3 return(condition);4}Copia
Tuttavia, esistono dei problemi:
- Fallisce se
f
è dichiarata comeinternal
oexternal
. - Non è chiaro quali argomenti dovrebbero essere usati per chiamare
f
. - Se
f
si ripristina, la proprietà fallirà.
In generale, consigliamo di seguire i consigli di John Regehr(opens in a new tab) su come usare le affermazioni:
- Non forzare alcun effetto collaterale durante il controllo dell'affermazione. Ad esempio:
assert(ChangeStateAndReturn() == 1)
- Non affermare dichiarazioni ovvie. Ad esempio
assert(var >= 0)
dovevar
è dichiarata comeuint
.
Infine, consigliamo di non usare require
al posto di assert
, poiché Echidna non potrà rilevarlo (ma il contratto si ripristinerà ugualmente).
Riepilogo: Controllo dell'asserzione
Quanto segue riepiloga l'esecuzione di Echidna nel nostro esempio:
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}Mostra tuttoCopia
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: 1806480648350826486Mostra tutto
Echidna ha trovato che l'asserzione in inc
può fallire se questa funzione è chiamata diverse volte con grandi argomenti.
Raccogliere e modificare un corpus di Echidna
Vedremo come raccogliere e usare un corpus di transazioni con Echidna. L'obiettivo è il seguente smart contract 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 }1011 function echidna_magic_values() public returns (bool) {12 return !value_found;13 }1415}Mostra tuttoCopia
Questo piccolo esempio forza Echidna a trovare una certi valori per modificare una variabile di stato, il che è difficile per un fuzzer (si consiglia di usare uno strumento d'esecuzione simbolica come Manticore(opens in a new tab)). Possiamo eseguire Echidna per verificarlo:
echidna-test magic.sol...echidna_magic_values: passed! 🎉Seed: 2221503356319272685
Tuttavia, possiamo comunque usare Echidna per raccogliere il corpus mentre si esegue questa campagna di fuzzing.
Raccogliere un corpus
Per abilitare la raccolta, crea la cartella di un corpus:
mkdir corpus-magic
E un file di configurazione di Echidna(opens in a new tab) config.yaml
:
1coverage: true2corpusDir: "corpus-magic"
Ora possiamo eseguire il nostro strumento e controllare il corpus raccolto:
echidna-test magic.sol --config config.yaml
Echidna non è ancora in grado di trovare i valori magici corretti, ma possiamo dare un'occhiata al corpus raccolto. Ad esempio, uno di questi file 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]Mostra tuttoCopia
Chiaramente, questo input non innescherà il fallimento nella nostra proprietà. Tuttavia, nel prossimo passaggio, vedremo come modificarlo a tale scopo.
Seeding di un corpus
Echidna necessita di un po' di aiuto per poter affrontare la funzione magic
. Copieremo e modificheremo l'input per usare parametri idonei a tale scopo:
cp corpus/2712688662897926208.txt corpus/new.txt
Modificheremo new.txt
per chiamare magic(42,129,333,0)
. Ora possiamo ri-eseguire Echidna:
echidna-test magic.sol --config config.yaml...echidna_magic_values: failed!💥Call sequence:magic(42,129,333,0)Unique instructions: 142Unique codehashes: 1Seed: -7293830866560616537Mostra tutto
Questa volta, ha scoperto che la proprietà è immediatamente violata.
Individuare le transazioni ad alto consumo di gas
Vedremo come individuare le transazioni con un alto consumo di gas con Echidna. L'obiettivo è il seguente smart contract:
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}Mostra tuttoCopia
Qui expensive
può avere un gran consumo di gas.
Attualmente, Echidna necessità sempre di una proprietà da testare: qui echidna_test
restituisce sempre true
. Possiamo eseguire Echidna per verificarlo:
1echidna-test gas.sol2...3echidna_test: passed! 🎉45Seed: 2320549945714142710
Misurare il Consumo di Gas
Per abilitare il consumo di gas con Echidna, crea un file di configurazione config.yaml
:
1estimateGas: true
In questo esempio, ridurremo anche le dimensioni della sequenza di transazione per facilitare la comprensione dei risultati:
1seqLen: 22estimateGas: true
Eseguire Echidna
Una volta creato il file di configurazione, possiamo eseguire Echidna come segue:
echidna-test gas.sol --config config.yaml...echidna_test: passed! 🎉f used a maximum of 1333608 gasCall sequence:f(42,123,249) Gas price: 0x10d5733f0a Time delay: 0x495e5 Block delay: 0x88b2Unique instructions: 157Unique codehashes: 1Seed: -325611019680165325Mostra tutto
- Il gas mostrato è una stima fornita da HEVM(opens in a new tab).
Filtrare le Chiamate di Riduzione del Gas
Il tutorial precedente sulle funzioni di filtraggio da chiamare durante una campagna di fuzzing, mostra come rimuovere alcune funzioni dal tuo test.
Questo può esser critico per ottenere una stima accurata del gas. Considera l'esempio seguente:
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}Mostra tuttoCopia
Se Echidna può chiamare tutte le funzioni, non troverà facilmente le transazioni a costo elevato di 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 gasMostra tutto
Questo perché il costo dipende dalla dimensione di addrs
e le chiamate casuali tendono a lasciare l'array quasi vuoto. L'inserimento in blacklist di pop
e clear
, tuttavia, fornisce risultati molto più efficaci:
1filterBlacklist: true2filterFunctions: ["pop", "clear"]
1echidna-test pushpop.sol --config config.yaml2...3push used a maximum of 40839 gas4...5check used a maximum of 1484472 gas
Sommario: Individuare le transazioni ad alto consumo di gas
Echidna può trovare le transazioni ad alto consumo di gas usando l'opzione di configurazione estimateGas
:
1estimateGas: true
echidna-test contract.sol --config config.yaml...
Echidna segnalerà una sequenza con il consumo massimo di gas per ogni funzione, una volta terminata la campagna di fuzzing.
Ultima modifica: @pettinarip(opens in a new tab), 4 dicembre 2023