Cum se folosește Echidna pentru a testa contracte inteligente
Instalare
Echidna poate fi instalat prin docker sau utilizând pachetul binar pre-compilat.
Echidna prin docker
docker pull trailofbits/eth-security-toolboxdocker run -it -v "$PWD":/home/training trailofbits/eth-security-toolbox
Ultima comandă rulează „eth-security-toolbox” într-un docker care are acces la directorul dvs. curent. Puteți modifica fișierele de pe gazda dvs. și puteți rula instrumentele pe fișierele din docker
În interiorul docker-ului, executați:
solc-select 0.5.11cd /home/training
Sursa binară
https://github.com/crytic/echidna/releases/tag/v1.4.0.0(opens in a new tab)
Introducere despre fuzzing-ul bazat pe proprietăți
Echidna este un fuzzer bazat pe proprietăți, așa cum am descris în articolele noastre anterioare de pe blog (1(opens in a new tab), 2(opens in a new tab), 3(opens in a new tab)).
Fuzzing
Fuzzing-ul(opens in a new tab) este o tehnică binecunoscută în comunitatea de securitate. Aceasta constă în generarea de date de intrare mai mult sau mai puțin aleatorii pentru a găsi bug-uri în program. Se știe că fuzzer-ii pentru software-uri tradiționale (cum ar fi AFL(opens in a new tab) sau LibFuzzer(opens in a new tab)) sunt instrumente eficiente pentru a găsi bug-uri.
În afară de generarea pur aleatorie a datelor de intrare, există numeroase tehnici și strategii pentru a genera date bune de intrare, printre care se numără:
- Obținerea feedback-ului de la fiecare execuție și orientarea generării cu ajutorul acestuia. De exemplu, dacă datele de intrare nou generate conduc la descoperirea unei noi căi, pare normal să fie generate noi date de intrare apropiate de acestea.
- Generarea datelor de intrare respectând o obligație structurală. De exemplu, dacă datele dvs. de intrare conțin un antet cu o sumă de control, va fi logic să lăsați fuzzer-ul să genereze date de intrare care validează suma de control.
- Utilizarea datelor de intrare cunoscute pentru a genera noi date de intrare: dacă aveți acces la un set mare de date de intrare valide, fuzzer-ul dvs. poate genera noi date de intrare din acestea, în loc să înceapă generarea acestora de la zero. Acestea se numesc de regulă seeds.
Fuzzing-ul bazat pe proprietăți
Echidna aparține unei familii specifice de fuzzer-i: fuzzing-ul bazat pe proprietăți inspirat puternic de QuickCheck(opens in a new tab). Spre deosebire de fuzzer-ii clasici, care încearcă să găsească erori, Echidna încearcă să spargă invarianții definiți de utilizator.
În contractele inteligente, invarianții sunt funcții Solidity care pot reprezenta orice stare incorectă sau nevalidă pe care o poate atinge un contract, inclusiv:
- Controlul incorect al accesului: atacatorul a devenit proprietarul contractului.
- Mașină de stare incorectă: tokenurile pot fi transferate în timp ce contractul este în pauză.
- Aritmetică incorectă: utilizatorul, printr-un „underflow” al soldului său, poate obține un număr nelimitat de tokenuri gratuite.
Testarea unei proprietăți cu Echidna
Vom vedea cum să testăm un contract inteligent cu Echidna. Obiectivul este următorul contract inteligent 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}Afișează totCopiați
Vom presupune că acest token trebuie să aibă următoarele proprietăți:
- Oricine poate avea cel mult 1000 de tokenuri
- Tokenul nu poate fi transferat (nu este un token ERC20)
Scrieți o proprietate
Proprietățile Echidna sunt funcții Solidity. O proprietate trebuie să:
- nu aibă niciun argument
- răspundă prin
true
dacă are succes - aibă un nume care să înceapă cu
echidna
Echidna va face următoarele:
- va genera automat tranzacții arbitrare pentru a testa proprietatea.
- va raporta orice tranzacție care face ca o proprietate să răspundă prin
false
sau să genereze o eroare. - va elimina efectul secundar atunci când apelează o proprietate (adică, dacă proprietatea modifică o variabilă de stare, aceasta este eliminată după test)
Următoarea proprietate verifică dacă apelantul nu are mai mult de 1000 de tokenuri:
1function echidna_balance_under_1000() public view returns(bool){2 return balances[msg.sender] <= 1000;3}Copiați
Utilizați moștenirea („inheritance”) pentru a vă separa contractul de proprietăți:
1contract TestToken is Token{2 function echidna_balance_under_1000() public view returns(bool){3 return balances[msg.sender] <= 1000;4 }5 }Copiați
token.sol
(opens in a new tab) implementează proprietatea și moștenește de la token.
Inițiați un contract
Echidna are nevoie de un constructor fără argument. În cazul în care contractul dvs. are nevoie de o inițializare anume, trebuie să o faceți în cadrul constructorului.
Există câteva adrese specifice în Echidna:
0x00a329c0648769A73afAc7F9381E08FB43dBEA72
care apelează constructorul.0x10000
,0x20000
și0x00a329C0648769a73afAC7F9381e08fb43DBEA70
care apelează aleatoriu celelalte funcții.
Nu avem nevoie de nicio inițializare specială în exemplul nostru actual, de aceea constructorul nostru este gol.
Executați Echidna
Echidna se lansează cu:
$ echidna-test contract.sol
În cazul în care „contract.sol” conține mai multe contracte, puteți specifica ținta:
$ echidna-test contract.sol --contract MyContract
Rezumat: testarea unei proprietăți
Ceea ce urmează sintetizează cum se execută Echidna în exemplul nostru:
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ți
$ echidna-test testtoken.sol --contract TestToken...echidna_balance_under_1000: failed!💥Call sequence, shrinking (1205/5000):airdrop()backdoor()...Afișează tot
Echidna a constatat că proprietatea este încălcată dacă se apelează funcția backdoor
.
Filtrarea funcțiilor care trebuie apelate în timpul unei campanii de fuzzing
Vom vedea cum să filtrăm funcțiile care urmează să fie fuzzate. Obiectivul este următorul contract inteligent:
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}Afișează totCopiați
Acest mic exemplu forțează Echidna să găsească o anumită secvență de tranzacții pentru a modifica o variabilă de stare. Acest lucru este dificil pentru un fuzzer (se recomandă utilizarea unui instrument simbolic de execuție precum Manticore(opens in a new tab)). Pentru a verifica aceasta, putem executa Echidna:
$ echidna-test multi.sol...echidna_state4: passed! 🎉Seed: -3684648582249875403
Funcții de filtrare
Este dificil pentru Echidna să găsească secvența corectă pentru a testa acest contract, pentru că cele două funcții de resetare (reset1
și reset2
) vor configura toate variabilele de stare ca false
. Totuși, putem utiliza o funcție specială Echidna fie pentru a pune pe lista neagră funcția de resetare, fie pentru a pune pe lista albă doar funcțiile f
, g
, h
și i
.
Pentru a pune funcțiile pe lista neagră, putem folosi acest fișier de configurare:
1filterBlacklist: true2filterFunctions: ["reset1", "reset2"]
O altă abordare pentru filtrarea funcțiilor este să listăm funcțiile de pe lista albă. Pentru a face aceasta, putem utiliza acest fișier de configurare:
1filterBlacklist: false2filterFunctions: ["f", "g", "h", "i"]
filterBlacklist
estetrue
în mod implicit.- Filtrarea va fi efectuată numai după nume (fără parametri). Dacă aveți
f()
șif(uint256)
, filtrul”f”
va marca ambele funcții ca fiind corespunzătoare.
Executați Echidna
Pentru a executa Echidna cu un fișier de configurare blacklist.yaml
:
$ echidna-test multi.sol --config blacklist.yaml...echidna_state4: failed!Secvența de apelare:f(12)g(8)h(42)i()
Echidna va găsi secvența de tranzacții de falsificare a proprietății aproape imediat.
Rezumat: Funcții de filtrare
Echidna poate pune pe lista neagră sau pe lista albă funcțiile care trebuie apelate în timpul unei campanii de fuzzing folosind:
1filterBlacklist: true2filterFunctions: ["f1", "f2", "f3"]
$ echidna-test contract.sol --config config.yaml...
Echidna pornește o campanie de fuzzing fie punând pe lista neagră f1
, f2
și f3
, fie apelându-le numai pe acestea, în funcție de valoarea booleanului filterBlacklist
.
Cum să testați aserțiunea Solidity cu Echidna
În acest scurt tutorial vom arăta cum se utilizează Echidna pentru a testa verificarea aserțiunilor în contracte. Să presupunem că avem un contract ca acesta:
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}Afișează totCopiați
Scrieți o aserțiune
Vrem să ne asigurăm că tmp
este mai mic sau egal cu counter
după ce a răspuns prin diferența acestora. Am putea scrie o proprietate Echidna, dar va trebui să stocăm undeva această valoare tmp
. În schimb, am putea folosi o afirmație ca aceasta:
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}Afișează totCopiați
Executați Echidna
To enable the assertion failure testing, create an Echidna configuration file(opens in a new tab) config.yaml
:
1checkAsserts: true
Când executăm acest contract în Echidna, obținem rezultatele așteptate:
$ echidna-test assert.sol --config config.yamlAnalizarea contractului: assert.sol:Incrementorassertion in inc: failed!Secvență de apeluri, micșorarea (2596/5000):inc(21711016731996786641919559689128982722488122124807605757398297001483711807488)inc(7237005577332262213973186563042994240829374041602535252466099000494570602496)inc(86844066927987146567678238756515930889952488499230423029593188005934847229952)Seed: 1806480648350826486Afișează tot
După cum puteți vedea, Echidna raportează unele eșecuri de aserțiune în funcția inc
. Este posibilă adăugarea mai multor aserțiuni pe funcție, dar Echidna nu poate determina care aserțiune a eșuat.
Când și cum se utilizează aserțiunile
Aserțiunile pot fi utilizate ca alternative la proprietățile explicite, mai ales atunci când condițiile de verificat sunt direct legate de utilizarea corectă a unei operații f
. Adding assertions after some code will enforce that the check will happen immediately after it was executed:
1function f(..) public {2 // some complex code3 ...4 assert (condition);5 ...6}7Copiați
Dimpotrivă, dacă se utilizează o proprietate Echidna explicită, tranzacțiile se vor executa aleatoriu și nu este ușor de a stabili momentul când se va efectua obligatoriu verificarea. Puteți totusi utiliza această soluție:
1function echidna_assert_after_f() public returns (bool) {2 f(..);3 return(condition);4}Copiați
Cu toate acestea, există unele probleme:
- Eșuează dacă
f
este declarat ca fiindinternal
sauexternal
. - Nu este prea clar ce argumente ar trebui utilizate pentru a apela
f
. - Dacă se inversează
f
, proprietatea va eșua.
În general, vă recomandăm să urmați recomandarea lui John Regehr(opens in a new tab) despre modul cum să utilizați aserțiunile:
- Nu impuneți niciun efect secundar în timpul verificării aserțiunilor. De exemplu:
assert(ChangeStateAndReturn() == 1)
- Nu faceți aserțiuni evidente. De exemplu,
assert(var >= 0)
în cazul în carevar
este declarat cauint
.
În cele din urmă, vă rugăm să nu utilizați require
în loc de assert
, deoarece Echidna nu va putea detecta acest lucru (dar contractul se va inversa oricum).
Rezumat: verificarea aserțiunilor
Ceea ce urmează sintetizează cum se execută Echidna în exemplul nostru:
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}Afișează totCopiați
$ 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: 1806480648350826486Afișează tot
Echidna a constatat că aserțiunea din inc
poate eșua dacă această funcție este apelată de mai multe ori cu argumente mari.
Colectarea și modificarea unui corpus Echidna
Vom vedea cum putem colecta și utiliza un corpus de tranzacții cu Echidna. Obiectivul este următorul contract inteligent 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}Afișează totCopiați
Acest mic exemplu forțează Echidna să găsească anumite valori pentru a modifica o variabilă de stare. Acest lucru este dificil pentru un fuzzer (se recomandă utilizarea unui instrument simbolic de execuție precum Manticore(opens in a new tab)). Pentru a verifica aceasta, putem executa Echidna:
$ echidna-test magic.sol...echidna_magic_values: passed! 🎉Seed: 2221503356319272685
Cu toate acestea, putem folosi în continuare Echidna pentru a colecta corpus atunci când executăm această campanie de fuzzing.
Colectarea unui corpus
Pentru a activa colectarea unui corpus, creați un director corpus:
$ mkdir corpus-magic
Și un fișier de configurare Echidna(opens in a new tab) config.yaml
:
1coverage: true2corpusDir: "corpus-magic"
Acum putem să rulăm instrumentul nostru și să verificăm corpusul colectat:
$ echidna-test magic.sol --config config.yaml
Echidna tot nu poate găsi valorile „magic” corecte, dar ne putem uita la corpusul pe care l-a colectat. De pildă, unul dintre aceste fișiere 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]Afișează totCopiați
Este evident că aceste date de intrare nu vor declanșa eșecul în proprietatea noastră. Totuși, vom vedea în etapa următoare cum putem să o modificăm în acest sens.
Adăugarea de surse la un corpus
Echidna are nevoie de puțin ajutor pentru a se ocupa de funcția magic
. Vom copia și vom modifica datele de intrare ca să utilizăm parametri adecvați pentru aceasta:
$ cp corpus/2712688662897926208.txt corpus/new.txt
Vom modifica new.txt
pentru a apela magic(42,129,333,0)
. Acum putem executa din nou Echidna:
$ echidna-test magic.sol --config config.yaml...echidna_magic_values: failed!💥Call sequence:magic(42,129,333,0)Unique instructions: 142Unique codehashes: 1Seed: -7293830866560616537Afișează tot
This time, it found that the property is violated immediately.
Găsirea tranzacțiilor cu un consum ridicat de gaz
We will see how to find the transactions with high gas consumption with Echidna. Obiectivul este următorul contract inteligent:
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}Afișează totCopiați
Aici expensive
poate avea un consum mare de gaz.
Actualmente Echidna are totdeauna nevoie să testeze o proprietate: aici echidna_test
răspunde întotdeauna prin true
. Pentru a verifica aceasta, putem executa Echidna:
1$ echidna-test gas.sol2...3echidna_test: passed! 🎉45Seed: 2320549945714142710
Măsurarea consumului de gaz
To enable the gas consumption with Echidna, create a configuration file config.yaml
:
1estimateGas: true
În acest exemplu vom și reduce dimensiunea secvenței de tranzacții pentru a facilita înțelegerea rezultatelor:
1seqLen: 22estimateGas: true
Run Echidna
După ce am creat fișierul de configurare, putem rula Echidna în felul următor:
$ 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: -325611019680165325Afișează tot
- Gazul afișat este o estimare furnizată de HEVM(opens in a new tab).
Filtrarea apelurilor de reducere a gazului
Tutorialul privind funcțiile de filtrare care trebuie apelate în timpul unei campanii de fuzzing de mai sus arată cum să eliminați unele funcții din testele dvs.
Acest lucru poate fi esențial pentru a obține o estimare precisă a gazului. Să considerăm următorul exemplu:
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}Afișează totCopiați
Dacă Echidna poate apela toate funcțiile, nu va găsi cu ușurință tranzacțiile cu un cost ridicat al gazului:
1$ echidna-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 gasAfișează tot
Aceasta deoarece costul depinde de mărimea addrs
și apelurile aleatorii tind să lase matricea aproape goală. Cu toate acestea, dacă punem pe lista neagră funcțiile pop
și clear
, obținem rezultate mult mai bune:
1filterBlacklist: true2filterFunctions: ["pop", "clear"]
1$ echidna-test pushpop.sol --config config.yaml2...3push used a maximum of 40839 gas4...5check used a maximum of 1484472 gas
Rezumat: găsirea tranzacțiilor cu un consum ridicat de gaz
Echidna poate găsi tranzacțiile cu un consum ridicat de gaz prin utilizarea opțiunii de configurare estimateGas
:
1estimateGas: true
$ echidna-test contract.sol --config config.yaml...
Echidna va raporta o secvență cu consumul maxim de gaz pentru fiecare funcție odată ce s-a încheiat campania de fuzzing.
Ultima modificare: @pettinarip(opens in a new tab), 4 decembrie 2023