The Ethernaut #2 i #3 – fallout & coin flip

Przemek/ 27 lutego, 2023

Pisanie bezpiecznych smart kontraktów wymaga wiele wiedzy oraz praktyki, a błędy popełnione w czasie tego procesu mogą prowadzić do bardzo poważnych konsekwencji. Dlatego wracamy do zabawy na platformie Ethernaut, która pomoże nam pisać lepszy kod. Nie przedłużając, bierzmy się do roboty i rozwiążmy wspólnie dwa kolejne wyzwania.

Zadanie #2 – fallout

Celem tego zadania jest przejęcie kontroli nad kontraktem. Innymi słowy, adres naszego portfela musi zostać przypisany do zmiennej “owner”. Zobaczmy zatem, jak wygląda kontrakt.

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "https://github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v3.3/contracts/math/SafeMath.sol";

contract Fallout {
  using SafeMath for uint256;
 mapping (address => uint) allocations;
 address payable public owner;

 /* constructor */
 function Fal1out() public payable {
   owner = msg.sender;
   allocations[owner] = msg.value;
 }

 modifier onlyOwner {
         require(
             msg.sender == owner,
             "caller is not the owner"
         );
         _;
     }

 function allocate() public payable {
   allocations[msg.sender] = allocations[msg.sender].add(msg.value);
 }

 function sendAllocation(address payable allocator) public {
   require(allocations[allocator] > 0);
   allocator.transfer(allocations[allocator]);
 }

 function collectAllocations() public onlyOwner {
   msg.sender.transfer(address(this).balance);
 }

function allocatorBalance(address allocator) public view returns (uint) {
   return allocations[allocator];
 }
}

Początek kontraktu to deklaracja użycia biblioteki SafeMath dla każdej zmiennej typu “uint256” oraz dwóch pól “allocations” i “owner”. Pierwsze z nich to mapowanie, w którym zapisywane będą informacje, ile dany użytkownik wpłacił środków, drugie, jak nazwa wskazuje, zawiera adres obecnego właściciela kontraktu.

Przechodząc dalej, widzimy funkcję “Fal1out”, oznaczoną komentarzem “constructor”. W przeszłości, kiedy Solidity nie posiadało opcji tworzenia konstruktorów, była to konwencja na tworzenie kodu, który posłuży do zainicjalizowania danych po wgraniu kontraktu. Dlatego tutaj ustawiane są wartości dla dwóch wcześniej wspomnianych pól. Jednak obecnie też możesz spotkać się z czymś podobnym, szczególnie jeśli będziesz miał do czynienia z tzw. upgradable smart contract, czyli kontraktami, które można aktualizować. Ale jest to temat na osobny wpis, dlatego nie przejmuj się, jeśli pierwszy raz o tym słyszysz. W przyszłości poruszę również tę kwestię:).

Kolejno znajdziemy prosty modyfikator, ograniczający dostęp jedynie dla właściciela kontraktu oraz cztery funkcje. Pierwsza z nich służy do wpłacania etherów na kontrakt i tym samym ustawia odpowiednią wartość w mapowaniu “allocations”. Następna pozwala przepisać swoje kryptowaluty do innego użytkownika. 

Funkcja “collectAllocations” daje możliwość wypłacenia wszystkich wszystkich środków znajdujących się w kontrakcie, ale jedynie jego właściciel może to zrobić. Czy czasem takie podejście nie pozwala okraść wszystkich użytkowników ;)?

Na samym końcu znajdziemy jeszcze kod, który pozwala na sprawdzenie obecnego salda dla podanego adresu. Jak zatem przejąć kontrolę nad tym kontraktem?

Jedyne miejsce, w którym zmieniana jest wartość w zmiennej “owner”, to funkcja “Fal1out”. Tyle że jest opatrzona komentarzem świadczącym, że pełnić ma ona rolę konstruktora. Zatem jak mielibyśmy ją wykorzystać?

Sprawa jest bardzo prosta. Kod ten nie jest konstruktorem, a jedynie zwykłą funkcją, która może być wywoływana jak każda inna. Ze względu na to, że nie została ona zabezpieczona w żaden sposób i można jej używać do woli, nie ma znaczenia, czy ktoś już w przeszłości ją wykonał. Wystarczy zatem, że ze swojego adresu wywołamy tę funkcję i tym prostym sposobem właśnie przejęliśmy kontrolę nad kontraktem. Przypomnę, że aby to zrobić możesz wykorzystać konsolę w przeglądarce lub skorzystać np. ze środowiska Remix.

Może wydawać Ci się, że to wyzwanie było bardzo trywialne i nic nie wnosi. Jednak możesz mi wierzyć, że przez taki właśnie błąd ucierpiał nie jeden kontrakt. Dlatego, jeżeli planujesz używać takiego podejścia do inicjalizacji kontraktów, to nie zapomnij wywołać funkcji zaraz po wgraniu kodu na blockchain oraz dodaj zabezpieczenie, które nie pozwoli na jej ponowne użycie.

Zadanie #3 – coin flip

Kolejna część będzie już nieco bardziej wymagająca, ale nie przejmuj się –  zaraz jej podołamy:) Wyzwanie to polega na zdobyciu 10 punktów z rzędu za poprawny “rzut monetą”. Przejrzyjmy kod, żeby dowiedzieć się, o co konkretnie chodzi.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract CoinFlip {

 uint256 public consecutiveWins;
 uint256 lastHash;
 uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;

 constructor() {
   consecutiveWins = 0;
 }

 function flip(bool _guess) public returns (bool) {
   uint256 blockValue = uint256(blockhash(block.number - 1));

   if (lastHash == blockValue) {
     revert();
   }

   lastHash = blockValue;
   uint256 coinFlip = blockValue / FACTOR;
   bool side = coinFlip == 1 ? true : false;

   if (side == _guess) {
     consecutiveWins++;
     return true;
   } else {
     consecutiveWins = 0;
     return false;
   }
 }
}

Kontrakt przechowuje trzy pola. Pierwsze z nich o nazwie “consecutiveWins” będzie przechowywać informacje o zdobytych punktach, “lastHash” posłuży do zapisania hashu ostatniego bloku, natomiast “FACTOR” jest po prostu dużą liczbą. W konstruktorze nie dzieje się nic ciekawego, więc idziemy dalej.

Tutaj dochodzimy do jedynej funkcji w kontrakcie o nazwie “flip”, która jako parametr przyjmuje wartość typu boolean. Na samym jej początku sczytywany jest hash poprzedniego bloku, który zostaje przekonwertowany na liczbę. Następnie następuje sprawdzenie, czy otrzymana dana nie równa się czasem informacji przechowywanej w polu “lastHash”. Jeśli dane będą takie same, to nastąpi przerwanie funkcji, w odwrotnym przypadku kod idzie dalej i następuje aktualizacja wartości “lastHash”. Takie podejście sprawia, że nie będzie można wielokrotnie wywołać ten funkcji w jednym bloku i za każdym razem w dalszych obliczeniach będzie wykorzystywana inna wartość, co daje tutaj pewnego rodzaju losowość.

Kolejny krok to proste działanie, którego wynikiem jest dzielenie liczby reprezentującej poprzedni hash przez pole “FACTOR”. Jeżeli rezultat jest równy 1, to do zmiennej “side” przypisywana jest wartość “true” oraz w przeciwnym razie “false”.

W ten sposób dochodzimy do ostatniego kroku i jeżeli ostatnio otrzymany wynik będzie identyczny z parametrem, który wprowadziliśmy do funkcji,  to nasz licznik zwycięstw rośnie, jeśli nie – to jest on całkowicie zerowany. Teraz możemy skupić się nad rozwiązaniem.

Dla przypomnienia naszym celem jest zdobycie 10 punktów, a żeby to zrobić, musimy 10 razy z rzędu wywołać funkcję “flip” i za każdym razem dobrze wytypować przekazywany do niej parametr. Ponieważ za każdym razem wynik działań może być różny, ze względu na zmieniającą się jego zmienną, jaką jest hash poprzedniego bloku, zadanie wydawać się może niemożliwe. Jednak nic bardziej mylnego i już spieszę z wytłumaczeniem.

Kluczem do rozwiązania tego wyzwania jest odgadnięcie hashu bloku, który poprzedza blok, w którym znajdzie się nasza transakcja. Teoretycznie możemy sprawdzać to “ręcznie”, a następnie wykonać wszystkie obliczenia na kalkulatorze, co pomoże nam odgadnąć wymagany parametr. Szkopuł w tym, że nie wiemy, czy nasza transakcja znajdzie się na 100% w kolejnym bloku, czy może zostanie ona zaakceptowana później i wtedy całe obliczenia na marne. Ostatecznie jej wciągnięcie w blockchain zależy przecież od walidatorów. Gdybyśmy byli jednak w stanie sprawić, że znalezienie hashu, wykonanie obliczeń, odgadnięcie parametru i wywołanie funkcji znajdzie się w obrębie jednej transakcji, to jesteśmy w domu. Wszystko to jesteśmy w stanie zrobić z pomocą kodu, dlatego napiszmy smart contract, który nam to umożliwi.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.0;

import "./CoinFlip.sol";

contract Hacker {
   uint256 constant FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
   CoinFlip immutable game;
   
   constructor(CoinFlip _game) {
       game = _game;
   }

   function attack() external {
       uint256 blockValue = uint256(blockhash(block.number - 1));
       uint256 coinFlip = blockValue / FACTOR;
       bool guess = coinFlip == 1 ? true : false;
       game.flip(guess);   
   }
}

Nasz nowo utworzony kontrakt, o jakże wyrafinowanej nazwie “Hacker”, przechowywać będzie dwie wartości. Pierwsza z nich to ta sama duża liczba, która była zapisana w wyzwaniu oraz adres wcześniej omawianego kontraktu. 

Możliwe, że rzuca Ci się właśnie w oczy fakt, że kod zawarty w funkcji “attack” wygląda bardzo podobnie do tego z funkcji “flip”. Jedyna różnica to usunięcie kilku niepotrzebnych linijek i dodanie na końcu jej wywołania. Teraz wystarczy jedynie, że ze swojego adresu wywołasz dziesięć razy – jedno po drugim funkcję “attack”, czym uzyskasz 10 punktów w grze. 

Dlaczego ma to prawo zadziałać? W takim podejściu nie interesuje nas, kiedy nasza transakcja wejdzie na blockchain. Ważne jest, że w jej trakcie nie zmieni się zmienna, jaką jest hash poprzedniego bloku. Reszta to już proste matematyczne obliczenia.

Jeżeli wszystko poszło zgodnie z planem, to wystarczy teraz, że zatwierdzisz wyzwanie na stronie Ethernaut i voila, kolejne zadanie ukończone.

Mimo że dzisiejsze zadania mogą po ich wykonaniu wydawać się banalnie proste, to mam nadzieję, że wyciągniesz z nich odpowiednie wnioski. Po pierwsze, kiedy tworzysz smart kontrakty, to do zainicjalizowania początkowych wartości używaj konstruktora, a jeśli tego nie możesz zrobić,  to zadbaj to, aby odpowiednia funkcja została wywołana zaraz po wgraniu kontraktu i żeby nie mógł jej wywołać już nikt więcej.

Druga lekcja do zapamiętania jest taka, że losowość oparta o dane, które da się wyciągnąć bezpośrednio z blockchaina to żadna losowość, a jej złamanie zajmie komputerowi ułamki sekund. Jeżeli chcesz stworzyć funkcjonalność odporną na takie ataki, to wykorzystaj do tego tzw. Wyrocznie (ang. Oracles). Przykład z dokładnym wytłumaczeniem, jak użyć tego mechanizmu znajdziesz w jednym z poprzednich postów.

Wbrew pozorom przedstawione scenariusze to nie jedynie wymysł twórców Ethernaut, ale sytuacje bazujące  na prawdziwych przypadkach. Jest tego dużo więcej, dlatego za jakiś czas wrócimy do kolejnym wyzwań:).

Share this Post
Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments