Jakie mogą być skutki błędów w kontraktach? Jednym z nich jest utrata środków, o czym niektórzy użytkownicy portfela Parity mieli okazję boleśnie przekonać się w lipcu tego roku. Blockchain nie zapomina, to co w nim zaszło nie może być odwołane. Jest to jego niewątpliwa zaleta, która w pewnych sytuacjach staje się wadą. Kontrakt rządzi się regułami takimi jakie zostały w nim zaimplementowane. Tylko je możemy wykorzystać, aby uratować nasze środki, jeśli zachodzi podejrzenie, że w kontrakcie nie są one już bezpieczne. Nawet jeśli zasady są błędne to nadal w kontrakcie są zasadami.
Dziura w Parity
Parity to jedna z implementacji klienta wraz z kontraktem portfela dla Ethereum. W poprzednim wpisie omówiłem inną implementację portfela jaką jest Ethereum Wallet.
W uproszczeniu implementacja portfela Parity składa się z dwóch kontraktów. Jeden z nich spełnia rolę biblioteki WalletLibrary
, która dostarcza implementacji funkcji wykorzystywanych przez kontrakt Wallet
. Technikę taką stosuje się, aby nie ponosić wysokich kosztów przy tworzeniu instancji kontraktów, jeśli część z ich kodu może być wspólna. Bibliotekę tworzymy w blockchain wyłącznie raz, instancji portfela będzie wiele.
Biblioteka WalletLibrary
dostarcza między innymi funkcję initWallet
, która była wywoływana z konstruktora portfela oraz funkcję changeOwner
, która służy do zmiany właściciela, a wołana była z metody changeOwner
. Poniżej przedstawiłem uproszczoną implementację tej części kontraktu Parity.
1contract WalletLibrary { 2 address public owner; 3 4 // funkcja wołana przy tworzeniu portfela 5 function initWallet(address _owner) { 6 owner = _owner; 7 // ... 8 } 9 10 // zmiana właściciela portfela 11 function changeOwner(address _new_owner) external { 12 // sprawdzamy czy funkcję wywołuje obecny właściciel 13 if (msg.sender == owner) { 14 owner = _new_owner; 15 } 16 } 17} 18 19contract Wallet { 20 address public _walletLibrary; 21 address public owner; 22 23 // konstruktor portfela, woła initWallet 24 function Wallet(address _owner) { 25 // ... 26 _walletLibrary.delegatecall(bytes4(sha3("initWallet(address)")), _owner); 27 } 28 29 // zmiana właściciela portfela 30 function changeOwner(address _new_owner) { 31 _walletLibrary.delegatecall(bytes4(sha3("changeOwner(address)")), _new_owner); 32 } 33 34 // funkcja awaryjna, wołana w razie braku możliwości dopasowania innej funkcji 35 function () payable { 36 _walletLibrary.delegatecall(msg.data); 37 } 38}
W tym miejscu należy wytłumaczyć jak działa delegatecall
. Wywołanie z poziomu kontraktu funkcji innego kontraktu za pomocą delegatecall
powoduje wykonanie kodu na rzecz wołającego kontraktu. Oznacza to, że delegatecall
do funkcji changeOwner
w rzeczywistości zmodyfikuje pole owner
konkretnej instancji kontraktu Wallet
a nie pole owner
w WalletLibrary
. Jest to sensowne zachowanie, wszak wszystkie portfele nie mają wspólnego właściciela.
Powyższa implementacja wykonuje delegatecall
z konstruktora do funkcji initWallet
oraz z changeOwner
do odpowiedniej funkcji w bibliotece.
Zauważmy, że funkcja initWallet
zmienia właściciela kontraktu bezwarunkowo. Wydaje się to poprawne, w konstruktorze dopiero tworzymy kontrakt, więc będzie to pierwszy właściciel. Konstruktor może być wywołany tylko raz. Funkcja changeOwner
weryfikuje czy to aktualny właściciel ją wywołuje i tylko wtedy pozwala na ustawienie nowego adresu.
Wewnątrz Ethereum Virtual Machine wywołanie funkcji kontraktu z innego kontraktu polega na wyliczeniu jej sygnatury oraz przekazaniu (doklejeniu do sygnatury) wartości parametrów. Sygnatura funkcji to cztery bajty ze skrótu jej nazwy wraz z typami parametrów. Wyliczana jest ona przez kontrakt z ciągu znaków. W powyższym przykładzie dla initWallet(address)
będzie to 9da8be21
. Zatem taką sygnaturę można wyliczyć samodzielnie poza kontraktem. Czy w przedstawionym kontrakcie można w jakiś sposób wywołać funkcję initWallet
gdy kontrakt jest już utworzony? Przyjrzyjmy się implementacji funkcji bez nazwy, która jest tak zwaną funkcją awaryjną (ang. fallback) wołaną w razie braku możliwości dopasowania innej funkcji. Wykonuje ona delegatecall
podając jako parametr dane przekazane w transakcji. Bingo! Wystarczy zatem doprowadzić do wywołania funkcji awaryjnej z sygnaturą initWallet(address)
podając jako parametr wybrany przez nas adres nowego właściciela. To działało, ponieważ kontrakt Wallet
nie miał implementacji funkcji o sygnaturze initWallet(address)
, więc wywołanie trafiało do funkcji awaryjnej i było przekazywane do biblioteki WalletLibrary
, po czym wykonywało się bez przeszkód na rzecz kontraktu wołającego.
Błąd w kontrakcie Parity polegał na tym, że możliwość wywołania funkcji initWallet
nie była ograniczona tylko do przypadku procesu tworzenia nowego portfela. Został on wykorzystany przez atakujących do wyprowadzenia Etherów z istniejących kontraktów. Straty osiągnęły kwotę kilkudziesięciu milionów dolarów, a byłyby jeszcze większe gdyby w porę nie zareagowali dobrzy hakerzy, którzy zabezpieczyli środki z błędnych kontraktów poprzez wykorzystanie tego samego ataku. Różnica polegała na tym, że zwrócili potem wyprowadzone środki prawowitym właścicielom kontraktów.
Poprawka w kontrakcie Parity dodatkowo objęła funkcje, które bezwarunkowo modyfikują adres właściciela. Powinny być funkcjami internal
, co oznacza, że nie ma możliwości wywołania ich spoza kontraktu. Trzeba pamiętać, że w języku Solidity domyślnie funkcje mają status public
.
Łatkę w portfelu Parity, która eliminowała te błędy można zobaczyć pod tym
adresem.
Cytując niektóre z komentarzy: internal
wart 30 milionów dolarów.
Podsumowanie
Powyższy przykład pokazuje jak dużą ostrożność trzeba zachować implementując kontrakty w Solidity i wykorzystując przygotowany kod jako ich użytkownik. Niuanse języka Solidity oraz maszyny wirtualnej EVM mogą mieć fatalne skutki dla kontraktów i ich właścicieli. Rozwijanych jest wiele narzędzi, które pomagają w analizie kodu kontraktów. Stosowane są też różne techniki, jak na przykład dzienny limit wypłat, które pozwalają zminimalizować skutki wykorzystania potencjalnych dziur. Wiele sprytnych ataków jeszcze się pojawi. Na pewno jednym ze sposobów na ich uniknięcie jest nauka poprzez implementację różnych kontraktów i ich analiza we własnej sieci Ethereum.