Po pierwsze, pomogę Ci w prosty sposób zrozumieć, czym jest atak ponownego wejścia i jak możesz mu zapobiec, a następnie zagłębię się w przykłady kodu, aby pokazać, gdzie są luki i jaki byłby kod atakującego a co najważniejsze pokażę Ci najnowsze sprawdzone metody ochrony nie tylko jednego, ale wszystkich inteligentnych kontraktów w Twoim projekcie.
Spoiler: Jeśli słyszałeś już o modyfikatorze nonReentrant(), czytaj dalej, ponieważ za chwilę odkryjesz kilka wierszy poniżej modyfikatora globalNonReentrant() i wzorca kontroli-efektów-interakcji.
Na powyższym obrazku mamy KontraktA i KontraktB. Teraz, jak wiesz, inteligentny kontrakt może wchodzić w interakcję z innym inteligentnym kontraktem, tak jak w tym przypadku ContractA może wywołać ContractB. Zatem podstawową ideą ponownego wejścia jest to, że KontraktB może oddzwonić do KontraktA, gdy KontraktA jest nadal wykonywany.
Jak więc atakujący może to wykorzystać?
Powyżej mamy KontraktA, który ma 10 eterów i widzimy, że KontraktB przechowuje 1 Eter w KontraktA. W takim przypadku KontraktB będzie mógł skorzystać z funkcji wypłaty z Kontraktu A i wysłać Ether z powrotem do siebie, gdy przejdzie kontrolę, w której jego saldo jest większe niż 0, aby następnie jego całkowite saldo zostało zmodyfikowane do 0.
Zobaczmy teraz, jak KontraktB może wykorzystać funkcję ponownego wejścia, aby wykorzystać funkcję wypłaty i ukraść wszystkie Etery z Kontraktu A. Zasadniczo atakujący będzie potrzebował dwóch funkcji: ataku() i rezerwy().
W Solidity funkcja rezerwowa jest funkcją zewnętrzną, która nie ma nazwy, parametrów ani zwracanych wartości. Każdy może wywołać funkcję rezerwową poprzez: Wywołanie funkcji, która nie istnieje w kontrakcie; Wywołanie funkcji bez przekazywania wymaganych danych; Wysyłanie Etheru bez żadnych danych do umowy.
Sposób działania ponownego wejścia (śledźmy krok po kroku strzałki) polega na tym, że atakujący wywołuje funkcję ataku(), która wewnątrz wywołuje funkcję wycofania() z ContractA. Wewnątrz funkcji sprawdzi, czy saldo Kontraktu B jest większe niż 0 i jeśli tak, będzie kontynuować wykonanie.
Ponieważ saldo ContractB jest większe niż 0, wysyła on ten 1 Eter z powrotem i uruchamia funkcję rezerwową. Zauważ, że w tym momencie KontraktA ma 9 Eterów, a KontraktB ma już 1 Eter.
Następnie, gdy funkcja rezerwowa zostanie wykonana, ponownie uruchamia funkcję wypłaty Kontraktu A, ponownie sprawdzając, czy saldo Kontraktu B jest większe niż 0. Jeśli ponownie sprawdzisz powyższy obraz, zauważysz, że jego saldo nadal wynosi 1 eter.
Oznacza to, że czek przechodzi pomyślnie i wysyła kolejny Ether do ContractB, co uruchamia funkcję awaryjną. Zauważ, że ponieważ linia, w której mamy „saldo=0”, nigdy nie zostanie wykonana, będzie to kontynuowane, dopóki nie wyczerpie się cały Eter z Kontraktu.
___________
Przyjrzyjmy się teraz inteligentnemu kontraktowi, w którym możemy zidentyfikować ponowne wejście za pomocą kodu Solidity.
W umowie EtherStore mamy funkcję depozyt(), która przechowuje i aktualizuje salda nadawcy, a następnie funkcję wypłaty All(), która pobiera wszystkie zapisane salda na raz. Proszę zwrócić uwagę na implementację funkcji „withdrawAll(), która najpierw sprawdza z wymaganiem, czy saldo jest większe od 0, a zaraz po wysłaniu Etheru ponownie, pozostawiając na koniec aktualizację salda nadawcy do 0.
Tutaj mamy atak kontraktowy, który użyje ponownego wejścia do wyczerpania kontraktu EtherStore. Przeanalizujmy jego kod:
W jego konstruktorze osoba atakująca przekaże adres EtherStore, aby utworzyć instancję i dzięki temu móc korzystać z jej funkcji.
Widzimy tam funkcję fallback(), która zostanie wywołana, gdy EtherStore wyśle Ether do tego kontraktu. Wewnątrz będzie wywoływać wypłatę z EtherStore, o ile saldo jest równe lub większe niż 1.
Wewnątrz funkcji ataku() znajduje się logika, która będzie wykorzystywać EtherStore. Jak widzimy, najpierw zainicjujemy atak, upewniając się, że mamy wystarczającą ilość eteru, a następnie zdeponujemy 1 eter, aby saldo w EtherStore było większe niż 0, a tym samym przejdziemy kontrolę przed rozpoczęciem wypłaty.
Wyjaśniłem powyżej w przykładzie ContractA i ContractB krok po kroku, jak będzie działać kod, więc teraz zróbmy podsumowanie, jak to będzie. Przede wszystkim atakujący wywoła metodę ataku(), która wewnątrz wywoła metodę wycofaniaAll() z EtherStore, co następnie wyśle funkcję rezerwową kontraktu Ether do Attack. Tam rozpocznie się ponowne wejście i opróżni saldo EtherStore.
Jak więc możemy chronić nasze umowy przed atakami typu reentranty?
Pokażę Ci trzy techniki profilaktyki, aby w pełni je chronić. Omówię, jak zapobiegać ponownemu wejściu w pojedynczej funkcji, funkcji krzyżowej ponownego wejścia i umowie krzyżowej ponownego wejścia.
Pierwszą techniką ochrony pojedynczej funkcji jest użycie modyfikatora o nazwie noReentrant.
Modyfikator to specjalny typ funkcji, którego używasz do modyfikowania zachowania innych funkcji. Modyfikatory umożliwiają dodanie dodatkowych warunków lub funkcjonalności do funkcji bez konieczności przepisywania całej funkcji.
W tym przypadku blokujemy kontrakt na czas wykonywania funkcji. W ten sposób nie będzie mógł ponownie wejść do pojedynczej funkcji, ponieważ będzie musiał przejrzeć kod funkcji, a następnie zmienić zmienną stanu zablokowanego na false, aby ponownie przejść kontrolę wykonaną w wymaganiu.
___________
Druga technika polega na wykorzystaniu wzorca Checks-Effects-Interactions, który ochroni nasze kontrakty przed ponownym wejściem między funkcjami. Czy w zaktualizowanej umowie EtherStore widzisz, co się zmieniło?
Aby głębiej zagłębić się we wzorzec Check-Effects-Interaction, polecam przeczytać https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html
Powyżej widzimy porównanie podatnego kodu z obrazu po lewej stronie, na którym saldo zostało zaktualizowane po wysłaniu Etheru, do którego, jak widać powyżej, potencjalnie nigdy nie można byłoby dotrzeć, a po prawej stronie dokonano przesunięcia sald[ msg.sender] = 0 (lub efekt) zaraz po require(bal > 0) (check), ale przed wysłaniem eteru (interakcja).
W ten sposób będziemy mieć pewność, że nawet jeśli inna funkcja uzyska dostęp do wycofania All(), kontrakt ten będzie chroniony przed atakującym, ponieważ saldo będzie zawsze aktualizowane przed wysłaniem Etheru.
Wzór stworzony przez https://twitter.com/GMX_IO
Trzecią techniką, którą Ci pokażę, jest utworzenie umowy GlobalReentrancyGuard w celu ochrony przed ponownym wejściem na rynek między umowami. Ważne jest, aby zrozumieć, że ma to zastosowanie do projektów obejmujących wiele umów wchodzących ze sobą w interakcję.
Pomysł jest tutaj taki sam, jak w modyfikatorze noReentrant, który wyjaśniłem w pierwszej technice, wchodzi do modyfikatora, aktualizuje zmienną, aby zablokować kontrakt i nie odblokowuje go, dopóki nie skończy kodu. Dużą różnicą jest to, że używamy zmiennej zapisanej w osobnym kontrakcie, która służy jako miejsce sprawdzenia, czy funkcja została wpisana, czy nie.
Stworzyłem tutaj przykład bez rzeczywistego kodu i tylko z nazwami funkcji w celach informacyjnych, aby zrozumieć pomysł, ponieważ z mojego doświadczenia może pomóc w wizualizacji sytuacji bardziej niż tylko pisanie jej słowami.
W tym przypadku osoba atakująca wywołałaby funkcję w kontrakcie ScheduledTransfer, która po spełnieniu warunków wysłałaby określony Ether do kontraktu AttackTransfer, który w związku z tym przeszedłby do funkcji awaryjnej i tym samym „anulował” transakcję z punktu kontraktu ScheduledTransfer widzieć, a mimo to otrzymywać Eter. W ten sposób rozpocznie się przeglądanie, aż do wyczerpania wszystkich eterów z ScheduledTransfer.
Cóż, użycie GlobalReentrancyGuard , o którym wspomniałem powyżej, pozwoli uniknąć takiego scenariusza ataku.
____
Twitter @TheBlockChainer, aby znaleźć więcej codziennych aktualizacji na temat inteligentnych kontraktów, bezpieczeństwa Web3, solidności, audytu inteligentnych kontraktów i nie tylko.
____