Dać komuś palec, a weźmie całą rękę – reentrancy attack
Bezpieczeństwo to jeden z kluczowych aspektów podczas tworzenia smart kontraktów. Mimo to wielu twórców wciąż nie przywiązuje zbyt wielkiej wagi do tego, co w konsekwencji prowadzi do kradzieży środków. Dzisiaj omówię jeden z bardziej znanych ataków, który przysporzył niejeden projekt o ból głowy, a mianowicie reentrancy attack.
O co tyle hałasu?
Jeżeli regularnie czytasz ten blog, to pewnie zdajesz już sobie sprawę, że rozwiązania oparte o blockchain to nie tylko śmieszne obrazki, ale również wielomiliardowe projekty.
Chociaż duża część z nich jest zdecentralizowana i nie ma możliwości sprzeniewierzenia znajdujących się tam środków przez twórców, jak miało to chociażby miejsce niedawno w przypadku giełdy FTX, to wciąż istnieją ryzyka związane z dziurami w kodzie. Zanim przejdę do głównego tematu dzisiejszego wpisu, przyjrzyjmy się kilku liczbom, które ukażą skalę problemu. Przy czym musisz pamiętać, że wszystkie włamania były spowodowane jedynie źle napisanym kodem, a nie problemami samej technologii blockchain. Ona wciąż jest bezpieczna:)
Jednym z najsłynniejszych ataków w historii był tzw. The DAO Hack. Doszło do niego w 2016 roku, a wartość wykradzionych środków szacowana była na około 60 mln $. Konsekwencją tego zdarzenia był rozdział Ethereum na dwa łańcuchy: Ethereum oraz Ethereum Classic. W tym pierwszym atak został “cofnięty”, natomiast w drugim jest on wciąż uwzględniany. Dziura, którą wykorzystał tutaj haker, była związana właśnie z atakiem reentrancy.
Innymi przypadkami protokołów, które uległy tej technice były np. Grim Finance (30 mln $) oraz Meerkat Finance (31 mln $), ale nie są to odosobnione sytuacje. Jednak reentrancy to nie jedyny sposób na kradzież, dlatego przejdźmy dalej bez zagłębiania się w szczegóły ataków.
2021 rok był bardzo intensywny na rynku krypto, dlatego w tym okresie doszło do wielu ataków. W maju PancakeBunny stracił 45 mln $, w sierpniu Poly Network 611 mln $ (jedna z największych kradzieży), we wrześniu Vee Finance 35 mln $, a w grudniu Badger DAO 120 mln$ oraz w tym samym miesiącu Vulca Forged 140 mln $, a to i tak tylko część z niechlubnych wydarzeń.
Niestety zła passa nie skończyła się w zeszłym roku, a ten obecny zasłynął z kradzieży środków z protokołu Ronin Network o wartości 614 mln $, co czyni to największą taką wpadką do tej pory.
Wspomniane wydarzenia to tylko część tego, co poszło nie tak, ale chyba już zdajesz sobie sprawę, że odpowiednie zabezpieczenie protokołów naprawdę jest na wagę złota.
Wejdę jeszcze raz zanim wyjdę
Znanych rodzajów ataków istnieje wiele, ale ponieważ blockchain, a tym bardziej smart kontrakty, są stosunkowo nową technologią, to cała masa innych podatności nie została jeszcze wykryta. Niemniej jednak dobrze wiedzieć, jak działają te już poznane. Dlatego teraz przejdę do szczegółów tego zwanego reentrancy.
W bardzo prostych słowach atak ten polega na ponownym wywołaniu funkcji smart kontraktu, zanim ta dobiegnie końca, a to prowadzi do uzyskania dostępu do większej ilości środków, niż nam się należy. Poniżej znajdziesz kod, który pomoże rozjaśnić całą sprawę.
pragma solidity 0.8.17;
contract Bank {
mapping(address => uint) balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 balance = balances[msg.sender];
require(balance > 0);
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
balances[msg.sender] = 0 ;
}
}
Kontrakt posiada dwie funkcje, jedną do wpłaty środków, drugą do ich odzyskania oraz mapowanie, które przechowuje informację o stanie poszczególnych kont. Skupmy się na funkcji służącej do wypłaty.
Na pierwszy rzut oka wszystko wydaje się okej. Na początku sprawdzamy czy użytkownik posiada jakieś środki, jeśli tak to wysyłamy mu je oraz zerujemy stan jego konta. Jednak z tak napisanego kontraktu, dowolna osoba może wyciągnąć nie tylko swoje środki, ale również wszystkie inne, które obecnie znajdują się w kontrakcie. Musi jednak do tego napisać smart kontrakt, który posłuży do ataku, ponieważ ze zwykłego adresu portfela (Externally owned account, EOA), nie będzie w stanie tego dokonać. Dodajmy kod, który posłuży do wydrenowania środków.
pragma solidity 0.8.17;
import "./Bank.sol";
pragma solidity 0.8.17;
import "./Bank.sol";
contract Hacker {
Bank bank;
constructor(address _bank) {
bank = Bank(_bank);
}
function attack() payable external {
bank.deposit{value: 1 ether }();
bank.withdraw(1 ether);
}
receive() external payable {
if(address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
}
}
Powyższy kontrakt składa się z dwóch funkcji. Pierwsza z nich posłuży do zainicjowania ataku. Na jej początku wpłacamy 1 ETH do banku, co pozwoli spełnić warunek o posiadanych środkach, a następnie inicjujemy wypłatę środków. Jednak cały myk polega na tym, że ten kontrakt posiada również zaimplementowaną funkcję receive, która jak może wiesz, wywoływana jest automatycznie kiedy na kontrakt wpłyną środki. Sprawi to, że w kontrakcie Bank zaraz po przesłaniu etherów cały proces zostanie przekierowany z powrotem do kontraktu Hacker i tak w kółko, dopóki warunek w funkcji receive jest prawdziwy. W efekcie dopiero po zakończeniu tej pętli wywołań zostanie zaktualizowane saldo adresu w kontrakcie banku.
Podatność ta może być w bardzo prosty sposób rozwiązana, wystarczy jedynie pamiętać o trzech podstawowych zasadach. Każda napisana funkcja powinna wykonywać operacje w następującej kolejności:
- sprawdzenie warunków
- aktualizacja stanu
- odwołania do zewnętrznych kontraktów
Jeżeli postąpimy zgodnie z powyższymi zasadami, to kontrakt przybierze następującą postać.
contract Bank {
mapping(address => uint) balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 balance = balances[msg.sender];
require(balance > 0);
balances[msg.sender] = 0 ;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
}
}
Zmiana była jedynie kosmetyczna, ponieważ aktualizacja konta użytkownika została przeniesiona przed wysłaniem środków, jednak w tym wypadku tyle wystarczy aby się zabezpieczyć.
Innym sposobem na poradzenie sobie z tą podatnością, jest użycie tzw. lock’a. Polega to na napisaniu kodu, który na samym początku funkcji ustawi dodatkową wartość, która nie pozwoli na ponowne wywołanie, przed jej zakończeniem.
pragma solidity 0.8.17;
contract Bank {
bool locked;
mapping(address => uint) balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
require(!locked, "Locked");
locked = true;
uint256 balance = balances[msg.sender];
require(balance > 0);
balances[msg.sender] = 0 ;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
locked = false;
}
}
W takim wypadku moglibyśmy aktualizować saldo nawet po wysłaniu środków, ale wciąż wszystko byłoby bezpieczne. Możemy też zastosować gotowe rozwiązanie dostarczone przez OpenZeppelin.
Osobiście jak patrzę na ten kod, to myślę jak bardzo niesamowite jest to, że taka drobna różnica może prowadzić do wielomilionowych strat. Jeszcze bardziej zastanawiające jest to, że atak ten jest znany od dawna, a wciąż można natknąć się na protokoły, które posiadają taką podatność, chociaż prawdopodobnie jest to już rzadkość.
Jednak dosyć niedawno została ujawniona bardziej skomplikowana wersja tego ataku o nazwie read-only reentrancy. Jeżeli interesuje Cię na czym polega ten wariant, to polecam Ci zapoznać się z tym materiałem, w którym autor tłumaczy wszystko ze szczegółami.
Dzisiejszy wpis możesz potraktować jako wprowadzenie w temat bezpieczeństwa, a ponieważ jest on bardzo istotny i szeroki, to jeszcze nie raz pojawi się na łamach tego bloga. Tymczasem kończę dzisiejszy wpis i mam nadzieję do zobaczenia za tydzień:).