Jak zhakować DAO – problem, który dotknął Tornado Cash

Przemek/ 10 czerwca, 2023

Kilka tygodni temu z protokołu o nazwie Tornado Cash zostały wyciągnięte aktywa o wartości 1 mln $. W świecie zdecentralizowanych rozwiązań różnego rodzaju kradzieże są dosyć częste. Jeśli zajrzysz na tę stronę, od razu zobaczysz dane, które pokazują, jak bardzo dotkliwy jest ten proceder. Jednak w przypadku Tornado Cash nie doszło do kompromitacji poprzez błąd w kodzie, co zazwyczaj się zdarza, lecz wykorzystano pewien mechanizm oraz nieuwagę ludzi. Dlatego dzisiaj przyjrzymy się temu, w jaki sposób można oszukać DAO.

Zaufajcie mi

Rysunek 1. Zaufanie
(źródło: http://www.quickmeme.com/meme/3r74zp)

Zarówno o DAO, jak i samym protokole Tornado Cash była już mowa na tym blogu i jeżeli wciąż te pojęcia są Ci obce to zajrzyj najpierw w te dwa miejsca:

Dla przypomnienia, DAO, czyli Decentralized Autonomous Organization, to taka struktura, w której decyzje i zmiany w projekcie są podejmowane poprzez głosowanie społeczności. Natomiast Tornado Cash to rozwiązanie zbudowane w formie DAO, którego celem jest umożliwienie anonimowych transakcji.

Co poszło nie tak ostatnio? W skrócie, 21 maja hakerowi udało się wprowadzić aktualizację, dzięki której przejął kontrolę nad protokołem, co umożliwiło mu pozyskanie ogromnych ilości tokena TORN, który determinuje prawo głosu. Część tokenów została sprzedana, co znacznie wzbogaciło rzezimieszka. Ostatnio sprawca zaproponował zmianę, która przywróciłaby moc głosów do stanu sprzed ataku, ale to, co już zyskał, pozostaje jego (lub jej) własnością.

Samo pozyskanie tokenów po przejęciu kontroli nad protokołem nie jest technicznie interesujące. Gdy haker miał pełną władzę, mógł robić, co tylko chciał. Jednak sposób, w jaki wykorzystał zgłaszanie pierwotnej aktualizacji, jest warty dogłębnego zrozumienia. Aby to wyjaśnić, musimy omówić kilka technicznych zagadnień.

Ten sam a inny, inny a ten sam

Każdy smart kontrakt, który wgrywamy na blockchain, posiada swój unikatowy adres. Ten adres jest uzyskiwany poprzez zahaszowanie adresu wgrywającego kontrakt, połączonego z tzw. nonce. Nonce to nic innego jak licznik wykonanych transakcji przez dany adres. Innymi słowy, każda operacja, którą wykonasz ze swojego portfela, zwiększa nonce przypisany do niego o 1. Analogicznie jest w przypadku transakcji wykonywanych przez smart kontrakt.

Aby lepiej to zobrazować, posłużmy się prostym przykładem.

contract DeployMe {
    function sayHello() external pure returns(string memory) {
        return "Hello";
    }
}

Teraz, przy użyciu Remixa, wgrajmy kontrakt na blockchain. Skorzystamy z środowiska Remix VM (Shanghai). Po wykonaniu operacji, uzyskany adres to 0xd9145CCE52D386f254917e481eB44e9943F39138. Jeśli spróbujemy ponownie wgrać dokładnie ten sam kod, znajdziemy go pod adresem 0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8. Mimo że kod był identyczny, zalazł się on w innej lokalizacji.

Teraz spróbujmy odświeżyć przeglądarkę i wykonać te same operacje ponownie. Okazuje się, że po pierwsze wgranie da nam adres 0xd9145CCE52D386f254917e481eB44e9943F39138, a drugie 0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8. Jest to związane z tym, że podczas odświeżania strony środowisko zostało zresetowane, a nonce używanego portfela został wyzerowany. W rezultacie przy wyliczaniu adresu zostały użyte dokładnie te same dane co poprzednio.

Aby mieć pewność, że tylko te dwa parametry biorą udział przy wyliczaniu adresu, zresetujmy środowisko jeszcze raz, ale tym razem przed wgraniem kontraktu rozszerzmy jego kod. Obecnie wygląda on następująco:

contract DeployMe {
    function sayHello() external pure returns(string memory) {
        return "Hello";
    }

    function sayGoodbye() external pure returns(string memory) {
        return "Goodbye";
    }
}

Jeżeli teraz spróbujemy wgrać ten kontrakt, adres, jaki uzyskamy, będzie identyczny jak w pierwszym przypadku. To powinno wystarczyć jako dowód.

Sprawdźmy teraz, czy podczas tworzenia nowych kontraktów, przy użyciu samych siebie, również uzyskamy takie wyniki. Do tego potrzebujemy dodatkowego kodu.

contract Deployer {
    address public deployedAt;

    function deploy() external{
        deployedAt = address(new DeployMe());
    }
}

Powyższy kontrakt umożliwia nam wgranie kodu pochodzącego z DeployMe i zapisuje adres, pod którym został on wgrany. Przed wykonaniem operacji odświeżamy stronę, aby wyzerować początkowy stan sieci.

Zaczynamy od wgrania kontraktu Deployer. Jego adres to ponownie 0xd9145CCE52D386f254917e481eB44e9943F39138. Teraz, przy użyciu tego kontraktu, wywołujemy operację deploy i odczytujemy wartość pola deployedAt. Powinniśmy otrzymać 0x5C9eb5D6a6C2c1B3EFc52255C0b356f116f6f66D. To jest nowy adres, który jeszcze wcześniej nie występował, ale jest to logiczne, ponieważ teraz adresem wgrywającego jest adres kontraktu Deployer, a nonce ma wartość początkową.

Przeprowadźmy jeszcze jeden test. Odświeżamy stronę i ponownie wgrywamy kontrakt Deployer. Tym razem, przed wywołaniem funkcji deploy, zmieniamy konto, którego używamy do wykonania transakcji, na dowolne inne niż domyślnie, a następnie wykonujemy operację. Okazuje się, że i tym razem otrzymujemy adres 0x5C9eb5D6a6C2c1B3EFc52255C0b356f116f6f66D. Oznacza to, że dane inicjujące początkową transakcję nie mają znaczenia, a liczy się tylko podmiot, która faktycznie tworzy kontrakt.

Tak działa podstawowy mechanizm, z którego zazwyczaj korzysta się przy tworzeniu nowych smart kontraktów. Istnieje jednak jeszcze jedna metoda, która całkowicie zmienia zasady gry.

Będę na Ciebie czekał zawsze w tym samym miejscu

Rysunek 3. Kościotrup
(źródło: https://makeameme.org/meme/im-still-waiting-5c33ec)

W 2018 roku Vitalik Buterin zaproponował zmianę EIP-1014, która wprowadza nową możliwość dotyczącą wgrywania smart kontraktów na blockchain o nazwie CREATE2. Dzięki tej zmianie jesteśmy w stanie wyliczyć adres nowo utworzonego smart kontraktu w inny sposób i, co więcej, możemy zawsze uzyskać ten sam wynik, niezależnie od liczby transakcji wykonanych przez dany adres.

W przypadku CREATE2 otrzymany wynik również zależy od adresu wgrywającego, ale zamiast nonce, uwzględniany jest kod bajtowy nowego kontraktu, stała wartość 0xFF oraz tzw. sól (ang. salt), która jest ustalana przez twórcę. Dzięki temu dane te dla poszczególnych kontraktów mogą być zawsze takie same. Pozostawmy pierwszy kontrakt bez zmian, ale zmodyfikujmy nieco ten drugi.

contract Deployer {
    address public deployedAt;

    function deploy() external{
        bytes32 salt = keccak256(abi.encode(0));
        deployedAt = address(new DeployMe{salt: salt}());
    }
}

Jak widzisz, zmiany są niewielkie i w zasadzie musimy jedynie dodać naszą sól. W tym przypadku będzie to wartość 0. Jeśli użyjemy powyższej składni, automatycznie zostanie wykorzystany mechanizm CREATE2.

Odświeżmy stronę, aby zrestartować stan, a następnie wgrajmy nasze kontrakty. Pierwszy z nich oczywiście będzie miał nam dobrze znany adres. Tutaj nic się nie zmienia, ponieważ ten mechanizm nie został ruszony. Wywołajmy teraz jego funkcję i sprawdźmy wynik. Okaże się, że tym razem otrzymany adres to 0x9659A581e1c8E1B9c75CAB0a7Eb0Fd1C4B05848C, czyli coś nowego. Wyraźnie inne dane zostały wykorzystane do jego wyliczenia.

Spróbujmy jeszcze raz wywołać funkcję deploy. Tym razem transakcja zakończy się niepowodzeniem, i żaden nowy kontrakt nie zostanie utworzony. Niezależnie od liczby prób, operacja nie powiedzie się, ponieważ adres, pod którym wgrywany jest kontrakt, jest już zajęty.

Moglibyśmy jeszcze sprawdzić, czy zmieniając kod kontraktu DeployMe, adresy się zmienią, ale zostawiam to już Tobie :). Ja skupię się na doprowadzeniu do sytuacji, w której ponowne wykonanie funkcji deploy zadziała. Aby tak się stało, musimy dodać jeszcze jedną modyfikację.

contract DeployMe {
    function sayHello() external pure returns(string memory) {
         return "Hello";
    }

    function sayGoodbye() external pure returns(string memory) {
         return "Goodbye";
    }

    function killMe() external {
        selfdestruct(payable(msg.sender));
    }
}

Tym razem dodaliśmy nową funkcję do naszego pierwotnego kontraktu o nazwie killMe, która implementuje mechanizm selfdestruct. Jeśli po raz pierwszy się z nim spotykasz, opisałem go tutaj, ale przypomnę, że pozwala on na usunięcie kontraktu.

Standardowo, odświeżmy stronę i powtórzmy operację wgrywania kontraktów. Pierwszy otrzymany adres pozostaje bez zmian, a drugi różni się, ponieważ zmieniliśmy kod kontraktu. Teraz musimy skopiować ten adres i użyć jego interfejsu oraz przycisku At Address, aby uzyskać możliwość wywoływania operacji na nim. Wydaje mi się, że już kilka razy to robiliśmy, więc wierzę, że wiesz o co chodzi :).

Mając dostęp do kontraktu DeployMe, wywołajmy jego metodę killMe. Następnie spróbujmy wykonać inną jego funkcję, aby upewnić się, że kontrakt został usunięty. Po naciśnięciu przycisku powinniśmy otrzymać błąd, co oznacza, że wszystko działa zgodnie z naszymi oczekiwaniami.

Spróbujmy teraz ponownie wgrać ten kontrakt na blockchain za pomocą kontraktu Deployer. Tym razem operacja powinna przebiec bez problemów, co oznacza, że kontrakt faktycznie został wgrany pod ten sam adres. Aby się upewnić, możemy jeszcze wywołać jego dowolną funkcję.

Łańcuch zdarzeń

Rysunek 4. Efekt motyla
(źródło: https://www.pinterest.com/pin/295478425525126857/)

W ataku dokonanym na Tornado Cash wykorzystano wszystkie wcześniej omówione mechanizmy. Spieszę z wyjaśnieniem, jak to się stało. Należy jednak zaznaczyć, że nasz przykład będzie bardzo uproszczony, bez tworzenia rzeczywistego DAO. Niemniej jednak przedstawiona sytuacja powinna dobrze objaśnić cały proces. Kod użyty w protokole Tornado Cash był znacznie bardziej złożony, ale w zasadzie opierał się na tych samych mechanizmach.

Wejdźmy w rolę hakerów i zastanówmy się, jakie mamy cele. Chcemy w tym przypadku doprowadzić do sytuacji, w której DAO zaakceptuje naszą propozycję, która na pierwszy rzut oka nie wzbudzi podejrzeń. Następnie, przed jej faktycznym wykonaniem, zamienimy kod, wprowadzając niepożądane zmiany. Jak to osiągnąć?

Już znamy zależności związane z generowaniem adresów nowych kontraktów, które musimy wykorzystać. Na początek należy stworzyć kod reprezentujący obie propozycje: prawdziwą i tę, która posłuży jako pułapka (wabik).

contract Proposal {
    uint256 public value = 1;

    function destroy() external {
        selfdestruct(payable(address(0)));
    }
}

contract MaliciousCode {
    uint256 public value = 2;
}

Kontrakt Proposal będzie reprezentował naszą pułapkę, którą chcemy pokazać DAO i poddać pod głosowanie. Drugi kontrakt będzie naszym nieuczciwym planem. Celem tej operacji jest zmienienie przechowywanej wartości z 1 na 2. Oczywiście, nie skupiamy się tutaj na tworzeniu złożonej logiki 🙂

Dodatkowo, pierwszy kontrakt posiada funkcję destroy, której zadaniem będzie usunięcie kodu, aby umożliwić jego podmianę.

Jeśli chcemy wgrać coś ponownie pod ten sam adres, pierwszym pomysłem, który może nam przyjść, jest użycie operacji CREATE2. Jednak w tym przypadku nie możemy z niej skorzystać, ponieważ posiadamy dwa różne kontrakty, co oznacza, że ostateczny adres będzie inny dla każdego z nich. Jednak z poprzednich przykładów wiemy, że podczas użycia standardowej metody tworzenia nowego kontraktu, miejsce jego wgrania jest wyliczane na podstawie adresu oraz liczby transakcji wykonującego. To właśnie ten mechanizm musimy wykorzystać. Stwórzmy zatem kontrakt, który to umożliwi.

contract Deployer {
    address public proposal;
    address public maliciousCode;

    function deployProposal() external {
        proposal = address(new Proposal());
    }

    function deployMaliciousCode() external {
        maliciousCode = address(new MaliciousCode());
    }

    function destroy() external {
        selfdestruct(payable(address(0)));
    }
}

Kod tego kontraktu nie jest skomplikowany. Składa się z trzech metod. Pierwsza metoda służy do utworzenia wabika, druga do wgrania prawdziwego kodu, a trzecia do zniszczenia tego kontraktu.

Teraz, jeśli uda nam się osiągnąć sytuację, w której po wgraniu i przegłosowaniu wabika, usuniemy wabik, usuniemy kontrakt Deployer, a następnie ponownie go zainstalujemy pod tym samym adresem, to nonce zostanie wyzerowany. Wówczas będziemy mogli wywołać funkcję deployMaliciousCode, która utworzy właściwy kod pod poprzednim adresem wabika. Aby to osiągnąć, musimy dodać jeszcze jeden krok do tego procesu.

contract DeployerDeployer {
    address public deployedAt;

    function deploy() external {
        bytes32 salt = keccak256(abi.encode(0));
        deployedAt = address(new Deployer{salt: salt}());
    }
}

Nazwa może nie być zbyt elegancka, ale jasno określa, co ten kontrakt ma zrobić. Jego celem jest wgranie kodu Deployer przy użyciu operacji CREATE2. Dzięki temu połączeniu możemy osiągnąć nasz cel. Jak więc wyglądałby ten proces?

Najpierw musimy wgrać kontrakt DeployerDeployer. Następnie wywołujemy jego funkcję deploy, która zainstaluje kolejny kod na blockchainie. Teraz pobieramy uzyskany adres i, operując na kolejnym kontrakcie, wywołujemy funkcję deployProposal. Nasz wabik jest gotowy. Możesz teraz sprawdzić, czy pod adresem propozycji faktycznie znajduje się wartość 1.

Kolejnym krokiem jest poddanie propozycji pod głosowanie. Tutaj nie będziemy tego robić, więc możemy założyć, że zaproponowana zmiana przeszła większością głosów i ustalono datę jej wdrożenia. Teraz czekamy spokojnie, aż ten moment nadejdzie, i tuż przed nim usuwamy najpierw kontrakt Proposal za pomocą funkcji destroy, a następnie wykonujemy to samo dla kontraktu Deployer.

Następnie przy pomocy naszego pierwszego kontraktu ponownie wgrywamy kod Deployer, co spowoduje, że znajdzie się on pod poprzednim adresem, ale z zresetowaną wartością nonce. Teraz wystarczy tylko wywołać funkcję deployMaliciousCode, i voilà. Możesz teraz sprawdziź, jaka wartość znajduje się pod adresem usuniętego wabika.

To jest cała sztuczka, którą wykorzystał haker. Jak już wspomniałem na początku, w tym przypadku kod nie zawiódł. Mechanizm DAO działał poprawnie, propozycja została przegłosowana, a następnie wykonana. Niestety, problem pojawił się po stronie ludzkiej, ponieważ społeczność nie dostrzegła potencjalnego ataku. Czy można było temu zapobiec?

Gdyby propozycja, która była wabikiem, nie zawierała mechanizmu usuwającego kontrakt, cała operacja nie miałaby szans powodzenia. Dlatego za każdym razem, gdy proponowana zmiana posiada możliwość wywołania funkcji selfdestruct, osoby uprawnione do głosowania powinny ją odrzucić. Możliwe, że w przyszłości ten problem zostanie automatycznie rozwiązany, ponieważ wiele osób uważa, że opcja wywołania tej metody powinna całkowicie zniknąć, i wszystko wskazuje na to, że tak się stanie w przyszłości.

Dzisiejszy wpis wyszedł trochę długi, ale mam nadzieję, że udało Ci się dotrzeć do końca. Niestety, hakerzy nie śpią i stale wymyślają coraz to bardziej intrygujące metody łamania protokołów. Blockchain oraz smart kontrakty są nadal stosunkowo nowe, ale przez nie przepływają ogromne pieniądze, co kusi wiele osób do podejmowania nieuczciwych działań. Dlatego tak ważne jest dbanie o bezpieczeństwo i nieustanne doskonalenie stworzonych rozwiązań.

Pamiętaj, że w świecie blockchaina, gdzie zdecentralizowane systemy i transparentność są priorytetem, każdy może przyczynić się do poprawy bezpieczeństwa. Wspólnoty blockchainowe, badacze i programiści pracują na rzecz identyfikowania i usuwania potencjalnych luk w systemach. Poprawa bezpieczeństwa wymaga współpracy i zaangażowania całej społeczności, abyśmy mogli cieszyć się korzyściami, jakie niesie ze sobą technologia blockchain.

Mam nadzieję, że ten wpis przybliżył ci zagadnienie ataku na DAO i metody, które hakerzy mogą stosować. Pamiętaj, że wiedza na ten temat jest istotna dla zrozumienia potencjalnych zagrożeń i podjęcia odpowiednich działań w celu zabezpieczenia systemów blockchainowych.

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