Przejdź do głównej treści

Jak używać Echidny do testowania inteligentnych kontraktów

Solidity
inteligentne kontrakty
bezpieczeństwo
testowanie
fuzzing
Zaawansowany
Trailofbits
10 kwietnia 2020
12 minut czytania

Instalacja

Echidnę można zainstalować za pomocą Dockera lub używając prekompilowanego pliku binarnego.

Echidna przez Dockera

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

Ostatnie polecenie uruchamia eth-security-toolbox w kontenerze Dockera, który ma dostęp do Twojego bieżącego katalogu. Możesz zmieniać pliki na swoim hoście i uruchamiać narzędzia na plikach z poziomu Dockera.

Wewnątrz Dockera uruchom:

solc-select 0.5.11
cd /home/training

Plik binarny

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

Wprowadzenie do fuzzingu opartego na właściwościach

Echidna to fuzzer oparty na właściwościach, który opisaliśmy w naszych poprzednich wpisach na blogu (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) to dobrze znana technika w społeczności zajmującej się bezpieczeństwem. Polega na generowaniu mniej lub bardziej losowych danych wejściowych w celu znalezienia błędów w programie. Fuzzery dla tradycyjnego oprogramowania (takie jak AFL (opens in a new tab) czy LibFuzzer (opens in a new tab)) są znane jako wydajne narzędzia do znajdowania błędów.

Poza czysto losowym generowaniem danych wejściowych, istnieje wiele technik i strategii generowania dobrych danych wejściowych, w tym:

  • Uzyskiwanie informacji zwrotnych z każdego wykonania i kierowanie generowaniem na ich podstawie. Na przykład, jeśli nowo wygenerowane dane wejściowe prowadzą do odkrywania nowej ścieżki, sensowne może być wygenerowanie nowych danych wejściowych zbliżonych do nich.
  • Generowanie danych wejściowych z uwzględnieniem ograniczeń strukturalnych. Na przykład, jeśli dane wejściowe zawierają nagłówek z sumą kontrolną, sensowne będzie pozwolenie fuzzerowi na generowanie danych wejściowych, które walidują tę sumę kontrolną.
  • Używanie znanych danych wejściowych do generowania nowych: jeśli masz dostęp do dużego zbioru prawidłowych danych wejściowych, Twój fuzzer może generować z nich nowe dane, zamiast zaczynać generowanie od zera. Są one zazwyczaj nazywane ziarnami (ang. seeds).

Fuzzing oparty na właściwościach

Echidna należy do specyficznej rodziny fuzzerów: fuzzingu opartego na właściwościach, silnie inspirowanego przez QuickCheck (opens in a new tab). W przeciwieństwie do klasycznego fuzzera, który będzie próbował znaleźć awarie (crashe), Echidna będzie próbowała złamać zdefiniowane przez użytkownika niezmienniki.

W inteligentnych kontraktach niezmienniki to funkcje w języku Solidity, które mogą reprezentować dowolny nieprawidłowy lub nieważny stan, jaki kontrakt może osiągnąć, w tym:

  • Nieprawidłowa kontrola dostępu: atakujący stał się właścicielem kontraktu.
  • Nieprawidłowa maszyna stanu: tokeny mogą być przesyłane, podczas gdy kontrakt jest wstrzymany.
  • Nieprawidłowa arytmetyka: użytkownik może doprowadzić do niedomiaru (underflow) swojego salda i uzyskać nieograniczoną liczbę darmowych tokenów.

Testowanie właściwości za pomocą Echidny

Zobaczymy, jak przetestować inteligentny kontrakt za pomocą Echidny. Celem jest następujący inteligentny kontrakt token.sol (opens in a new tab):

Założymy, że ten token musi mieć następujące właściwości:

  • Każdy może posiadać maksymalnie 1000 tokenów
  • Token nie może być przesyłany (nie jest to token ERC-20)

Pisanie właściwości

Właściwości Echidny to funkcje w języku Solidity. Właściwość musi:

  • Nie przyjmować żadnych argumentów
  • Zwracać true, jeśli zakończy się sukcesem
  • Mieć nazwę zaczynającą się od echidna

Echidna będzie:

  • Automatycznie generować arbitralne transakcje w celu przetestowania właściwości.
  • Zgłaszać wszelkie transakcje powodujące, że właściwość zwraca false lub zgłasza błąd.
  • Odrzucać efekty uboczne podczas wywoływania właściwości (tj. jeśli właściwość zmienia zmienną stanu, jest ona odrzucana po teście)

Poniższa właściwość sprawdza, czy wywołujący nie ma więcej niż 1000 tokenów:

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

Użyj dziedziczenia, aby oddzielić swój kontrakt od właściwości:

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

token.sol (opens in a new tab) implementuje właściwość i dziedziczy po tokenie.

Inicjowanie kontraktu

Echidna potrzebuje konstruktora bez argumentów. Jeśli Twój kontrakt wymaga specyficznej inicjalizacji, musisz to zrobić w konstruktorze.

W Echidnie istnieją pewne specyficzne adresy:

  • 0x00a329c0648769A73afAc7F9381E08FB43dBEA72, który wywołuje konstruktor.
  • 0x10000, 0x20000 oraz 0x00a329C0648769a73afAC7F9381e08fb43DBEA70, które losowo wywołują inne funkcje.

W naszym obecnym przykładzie nie potrzebujemy żadnej szczególnej inicjalizacji, w związku z czym nasz konstruktor jest pusty.

Uruchamianie Echidny

Echidnę uruchamia się za pomocą:

echidna-test contract.sol

Jeśli plik contract.sol zawiera wiele kontraktów, możesz określić cel:

echidna-test contract.sol --contract MyContract

Podsumowanie: Testowanie właściwości

Poniżej znajduje się podsumowanie działania Echidny na naszym przykładzie:

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

Echidna odkryła, że właściwość jest naruszana, jeśli wywołana zostanie funkcja backdoor.

Filtrowanie funkcji do wywołania podczas kampanii fuzzingowej

Zobaczymy, jak filtrować funkcje poddawane fuzzingowi. Celem jest następujący inteligentny kontrakt:

Ten mały przykład zmusza Echidnę do znalezienia określonej sekwencji transakcji w celu zmiany zmiennej stanu. Jest to trudne dla fuzzera (zaleca się użycie narzędzia do wykonywania symbolicznego, takiego jak Manticore (opens in a new tab)). Możemy uruchomić Echidnę, aby to zweryfikować:

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

Filtrowanie funkcji

Echidna ma problem ze znalezieniem prawidłowej sekwencji do przetestowania tego kontraktu, ponieważ dwie funkcje resetujące (reset1 i reset2) ustawią wszystkie zmienne stanu na false. Możemy jednak użyć specjalnej funkcji Echidny, aby dodać funkcję resetującą do czarnej listy (blacklist) lub dodać do białej listy (whitelist) tylko funkcje f, g, h i i.

Aby dodać funkcje do czarnej listy, możemy użyć tego pliku konfiguracyjnego:

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

Innym podejściem do filtrowania funkcji jest wypisanie funkcji na białej liście. Aby to zrobić, możemy użyć tego pliku konfiguracyjnego:

filterBlacklist: false
filterFunctions: ["f", "g", "h", "i"]
  • filterBlacklist jest domyślnie ustawione na true.
  • Filtrowanie będzie wykonywane tylko po nazwie (bez parametrów). Jeśli masz f() i f(uint256), filtr "f" dopasuje obie funkcje.

Uruchamianie Echidny

Aby uruchomić Echidnę z plikiem konfiguracyjnym blacklist.yaml:

echidna-test multi.sol --config blacklist.yaml
...
echidna_state4: failed!💥
  Call sequence:
    f(12)
    g(8)
    h(42)
    i()

Echidna niemal natychmiast znajdzie sekwencję transakcji falsyfikującą właściwość.

Podsumowanie: Filtrowanie funkcji

Echidna może dodawać funkcje do czarnej lub białej listy w celu ich wywoływania podczas kampanii fuzzingowej, używając:

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

Echidna rozpoczyna kampanię fuzzingową, dodając do czarnej listy f1, f2 i f3 lub wywołując tylko je, w zależności od wartości logicznej filterBlacklist.

Jak testować asercję w Solidity za pomocą Echidny

W tym krótkim samouczku pokażemy, jak używać Echidny do testowania sprawdzania asercji w kontraktach. Załóżmy, że mamy kontrakt taki jak ten:

Pisanie asercji

Chcemy upewnić się, że tmp jest mniejsze lub równe counter po zwróceniu ich różnicy. Moglibyśmy napisać właściwość Echidny, ale musielibyśmy gdzieś przechowywać wartość tmp. Zamiast tego moglibyśmy użyć asercji takiej jak ta:

Uruchamianie Echidny

Aby włączyć testowanie niepowodzeń asercji, utwórz plik konfiguracyjny Echidny (opens in a new tab) config.yaml:

checkAsserts: true

Kiedy uruchomimy ten kontrakt w Echidnie, otrzymamy oczekiwane wyniki:

Jak widać, Echidna zgłasza pewne niepowodzenie asercji w funkcji inc. Dodanie więcej niż jednej asercji na funkcję jest możliwe, ale Echidna nie potrafi określić, która asercja zawiodła.

Kiedy i jak używać asercji

Asercje mogą być używane jako alternatywy dla jawnych właściwości, zwłaszcza jeśli warunki do sprawdzenia są bezpośrednio związane z prawidłowym użyciem pewnej operacji f. Dodanie asercji po pewnym kodzie wymusi, że sprawdzenie nastąpi natychmiast po jego wykonaniu:

function f(..) public {
    // jakiś skomplikowany kod
    ...
    assert (condition);
    ...
}

Z drugiej strony, użycie jawnej właściwości Echidny spowoduje losowe wykonywanie transakcji i nie ma łatwego sposobu na wymuszenie dokładnego momentu jej sprawdzenia. Nadal możliwe jest zastosowanie tego obejścia:

function echidna_assert_after_f() public returns (bool) {
    f(..);
    return(condition);
}

Istnieją jednak pewne problemy:

  • Zawodzi, jeśli f jest zadeklarowane jako internal lub external.
  • Nie jest jasne, jakich argumentów należy użyć do wywołania f.
  • Jeśli f zostanie wycofana (revert), właściwość zawiedzie.

Ogólnie rzecz biorąc, zalecamy przestrzeganie rekomendacji Johna Regehra (opens in a new tab) dotyczących sposobu używania asercji:

  • Nie wymuszaj żadnych efektów ubocznych podczas sprawdzania asercji. Na przykład: assert(ChangeStateAndReturn() == 1)
  • Nie używaj asercji dla oczywistych stwierdzeń. Na przykład assert(var >= 0), gdzie var jest zadeklarowane jako uint.

Na koniec, prosimy nie używać require zamiast assert, ponieważ Echidna nie będzie w stanie tego wykryć (ale kontrakt i tak zostanie wycofany).

Podsumowanie: Sprawdzanie asercji

Poniżej znajduje się podsumowanie działania Echidny na naszym przykładzie:

Echidna odkryła, że asercja w inc może zawieść, jeśli ta funkcja zostanie wywołana wielokrotnie z dużymi argumentami.

Zbieranie i modyfikowanie korpusu Echidny

Zobaczymy, jak zebrać i użyć korpusu transakcji za pomocą Echidny. Celem jest następujący inteligentny kontrakt magic.sol (opens in a new tab):

Ten mały przykład zmusza Echidnę do znalezienia pewnych wartości w celu zmiany zmiennej stanu. Jest to trudne dla fuzzera (zaleca się użycie narzędzia do wykonywania symbolicznego, takiego jak Manticore (opens in a new tab)). Możemy uruchomić Echidnę, aby to zweryfikować:

echidna-test magic.sol
...

echidna_magic_values: passed! 🎉

Seed: 2221503356319272685

Niemniej jednak, nadal możemy używać Echidny do zbierania korpusu podczas uruchamiania tej kampanii fuzzingowej.

Zbieranie korpusu

Aby włączyć zbieranie korpusu, utwórz katalog korpusu:

mkdir corpus-magic

Oraz plik konfiguracyjny Echidny (opens in a new tab) config.yaml:

coverage: true
corpusDir: "corpus-magic"

Teraz możemy uruchomić nasze narzędzie i sprawdzić zebrany korpus:

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

Echidna nadal nie może znaleźć prawidłowych magicznych wartości, ale możemy przyjrzeć się zebranemu przez nią korpusowi. Na przykład, jednym z tych plików był:

Oczywiście, te dane wejściowe nie wywołają niepowodzenia w naszej właściwości. Jednak w następnym kroku zobaczymy, jak je zmodyfikować w tym celu.

Zasilanie korpusu (seeding)

Echidna potrzebuje trochę pomocy, aby poradzić sobie z funkcją magic. Skopiujemy i zmodyfikujemy dane wejściowe, aby użyć dla niej odpowiednich parametrów:

cp corpus/2712688662897926208.txt corpus/new.txt

Zmodyfikujemy new.txt, aby wywołać magic(42,129,333,0). Teraz możemy ponownie uruchomić Echidnę:

Tym razem natychmiast odkryła, że właściwość jest naruszana.

Znajdowanie transakcji o wysokim zużyciu gazu

Zobaczymy, jak znaleźć transakcje o wysokim zużyciu gazu za pomocą Echidny. Celem jest następujący inteligentny kontrakt:

Tutaj expensive może mieć duże zużycie gazu.

Obecnie Echidna zawsze potrzebuje właściwości do przetestowania: tutaj echidna_test zawsze zwraca true. Możemy uruchomić Echidnę, aby to zweryfikować:

echidna-test gas.sol
...
echidna_test: passed! 🎉

Seed: 2320549945714142710

Pomiar zużycia gazu

Aby włączyć pomiar zużycia gazu w Echidnie, utwórz plik konfiguracyjny config.yaml:

estimateGas: true

W tym przykładzie zmniejszymy również rozmiar sekwencji transakcji, aby wyniki były łatwiejsze do zrozumienia:

seqLen: 2
estimateGas: true

Uruchamianie Echidny

Gdy mamy już utworzony plik konfiguracyjny, możemy uruchomić Echidnę w ten sposób:

Odfiltrowywanie wywołań zmniejszających zużycie gazu

Powyższy samouczek dotyczący filtrowania funkcji do wywołania podczas kampanii fuzzingowej pokazuje, jak usunąć niektóre funkcje z testowania.
Może to mieć kluczowe znaczenie dla uzyskania dokładnego oszacowania gazu. Rozważmy następujący przykład:

Jeśli Echidna może wywołać wszystkie funkcje, nie znajdzie łatwo transakcji o wysokim koszcie gazu:

Dzieje się tak, ponieważ koszt zależy od rozmiaru addrs, a losowe wywołania mają tendencję do pozostawiania tablicy prawie pustej. Jednak dodanie pop i clear do czarnej listy daje nam znacznie lepsze wyniki:

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

Podsumowanie: Znajdowanie transakcji o wysokim zużyciu gazu

Echidna może znaleźć transakcje o wysokim zużyciu gazu, używając opcji konfiguracyjnej estimateGas:

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

Po zakończeniu kampanii fuzzingowej Echidna zgłosi sekwencję o maksymalnym zużyciu gazu dla każdej funkcji.

Ostatnia aktualizacja strony: 3 marca 2026