Przejdź do głównej treści

Inżynieria wsteczna kontraktu

evm
kody operacji
Zaawansowany
Ori Pomerantz
30 grudnia 2021
30 minut czytania

Wprowadzenie

Na blockchainie nie ma tajemnic, wszystko, co się dzieje, jest spójne, weryfikowalne i publicznie dostępne. W idealnym przypadku kontrakty powinny mieć swój kod źródłowy opublikowany i zweryfikowany w Etherscan (opens in a new tab). Jednak nie zawsze tak jest (opens in a new tab). W tym artykule dowiesz się, jak przeprowadzić inżynierię wsteczną kontraktów, analizując kontrakt bez kodu źródłowego, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Istnieją dekompilatory, ale nie zawsze generują one użyteczne wyniki (opens in a new tab). W tym artykule dowiesz się, jak ręcznie przeprowadzić inżynierię wsteczną i zrozumieć kontrakt na podstawie kodów operacji (opens in a new tab), a także jak interpretować wyniki działania dekompilatora.

Aby zrozumieć ten artykuł, powinieneś już znać podstawy EVM i być przynajmniej trochę zaznajomiony z asemblerem EVM. Możesz przeczytać o tych tematach tutaj (opens in a new tab).

Przygotowanie kodu wykonywalnego

Kody operacji możesz uzyskać, przechodząc do Etherscan dla danego kontraktu, klikając zakładkę Contract, a następnie Switch to Opcodes View. Otrzymasz widok, w którym w każdym wierszu znajduje się jeden kod operacji.

Opcode View from Etherscan

Aby jednak móc zrozumieć skoki, musisz wiedzieć, gdzie w kodzie znajduje się każdy kod operacji. Jednym ze sposobów, aby to zrobić, jest otwarcie Arkusza Google i wklejenie kodów operacji w kolumnie C. Możesz pominąć poniższe kroki, tworząc kopię tego już przygotowanego arkusza kalkulacyjnego (opens in a new tab).

Kolejnym krokiem jest uzyskanie prawidłowych lokalizacji w kodzie, abyśmy mogli zrozumieć skoki. Rozmiar kodu operacji umieścimy w kolumnie B, a lokalizację (w systemie szesnastkowym) w kolumnie A. Wpisz tę funkcję w komórce B1, a następnie skopiuj ją i wklej do reszty kolumny B, aż do końca kodu. Po wykonaniu tej czynności możesz ukryć kolumnę B.

=1+IF(REGEXMATCH(C1,"PUSH"),REGEXEXTRACT(C1,"PUSH(\d+)"),0)

Najpierw ta funkcja dodaje jeden bajt dla samego kodu operacji, a następnie szuka PUSH. Kody operacji PUSH są wyjątkowe, ponieważ muszą mieć dodatkowe bajty dla odkładanej wartości. Jeśli kodem operacji jest PUSH, wyodrębniamy liczbę bajtów i ją dodajemy.

W A1 wpisz pierwsze przesunięcie, czyli zero. Następnie w A2 wpisz tę funkcję i ponownie skopiuj ją i wklej do reszty kolumny A:

=dec2hex(hex2dec(A1)+B1)

Potrzebujemy tej funkcji, aby uzyskać wartość szesnastkową, ponieważ wartości, które są odkładane przed skokami (JUMP i JUMPI), są podawane w systemie szesnastkowym.

Punkt wejścia (0x00)

Kontrakty są zawsze wykonywane od pierwszego bajtu. Oto początkowa część kodu:

PrzesunięcieKod operacjiStos (po kodzie operacji)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREPusty
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIPusty

Ten kod robi dwie rzeczy:

  1. Zapisuje 0x80 jako 32-bajtową wartość w lokalizacjach pamięci 0x40-0x5F (0x80 jest przechowywane w 0x5F, a 0x40-0x5E to same zera).
  2. Odczytuje rozmiar danych wywołania. Zazwyczaj dane wywołania dla kontraktu Ethereum są zgodne z ABI (interfejsem binarnym aplikacji) (opens in a new tab), co wymaga co najmniej czterech bajtów dla selektora funkcji. Jeśli rozmiar danych wywołania jest mniejszy niż cztery, następuje skok do 0x5E.

Flowchart for this portion

Procedura obsługi pod adresem 0x5E (dla danych wywołania niezgodnych z ABI)

PrzesunięcieKod operacji
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Ten fragment zaczyna się od JUMPDEST. Programy EVM (wirtualnej maszyny Ethereum) zgłaszają wyjątek, jeśli wykonasz skok do kodu operacji, który nie jest JUMPDEST. Następnie sprawdza CALLDATASIZE i jeśli jest to „prawda” (czyli nie zero), skacze do 0x7C. Dojdziemy do tego poniżej.

PrzesunięcieKod operacjiStos (po kodzie operacji)
64CALLVALUE dostarczone przez wywołanie. Nazywane msg.value w Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Więc kiedy nie ma danych wywołania, odczytujemy wartość Storage[6]. Jeszcze nie wiemy, jaka to wartość, ale możemy poszukać transakcji, które kontrakt otrzymał bez danych wywołania. Transakcje, które po prostu transferują ETH bez żadnych danych wywołania (a zatem bez metody), mają w Etherscan metodę Transfer. W rzeczywistości pierwsza transakcja, jaką otrzymał kontrakt (opens in a new tab), to transfer.

Jeśli spojrzymy na tę transakcję i klikniemy Click to see More, zobaczymy, że dane wywołania, nazywane danymi wejściowymi (input data), są rzeczywiście puste (0x). Zauważ również, że wartość wynosi 1.559 ETH, co będzie miało znaczenie później.

The call data is empty

Następnie kliknij zakładkę State i rozwiń kontrakt, który poddajemy inżynierii wstecznej (0x2510...). Możesz zobaczyć, że Storage[6] zmieniło się podczas transakcji, a jeśli zmienisz Hex na Number, zobaczysz, że stało się 1,559,000,000,000,000,000, czyli wartością przetransferowaną w wei (dodałem przecinki dla jasności), odpowiadającą kolejnej wartości kontraktu.

Zmiana w Storage[6]

Jeśli spojrzymy na zmiany stanu spowodowane przez inne transakcje Transfer z tego samego okresu (opens in a new tab), zobaczymy, że Storage[6] przez pewien czas śledziło wartość kontraktu. Na razie nazwiemy to Value*. Gwiazdka (*) przypomina nam, że jeszcze nie wiemy, co robi ta zmienna, ale nie może to być tylko śledzenie wartości kontraktu, ponieważ nie ma potrzeby używania pamięci (storage), która jest bardzo droga, gdy można uzyskać saldo konta za pomocą ADDRESS BALANCE. Pierwszy kod operacji odkłada na stos własny adres kontraktu. Drugi odczytuje adres na szczycie stosu i zastępuje go saldem tego adresu.

PrzesunięcieKod operacjiStos
6CPUSH2 0x00750x75 Value* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Value* 0x75 0 6 CALLVALUE
70SWAP1Value* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

Będziemy kontynuować śledzenie tego kodu w miejscu docelowym skoku.

PrzesunięcieKod operacjiStos
1A7JUMPDESTValue* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE

NOT jest operacją bitową, więc odwraca wartość każdego bitu w wartości wywołania.

PrzesunięcieKod operacjiStos
1ACDUP3Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTValue*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROValue*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

Skaczemy, jeśli Value* jest mniejsze niż 2^256-CALLVALUE-1 lub równe tej wartości. Wygląda to na logikę zapobiegającą przepełnieniu (overflow). I rzeczywiście, widzimy, że po kilku bezsensownych operacjach (na przykład zapis do pamięci zaraz zostanie usunięty) pod przesunięciem 0x01DE kontrakt zostaje wycofany, jeśli wykryte zostanie przepełnienie, co jest normalnym zachowaniem.

Zauważ, że takie przepełnienie jest niezwykle mało prawdopodobne, ponieważ wymagałoby, aby wartość wywołania plus Value* była porównywalna do 2^256 wei, czyli około 10^59 ETH. Całkowita podaż ETH w momencie pisania tego tekstu wynosi mniej niż dwieście milionów (opens in a new tab).

PrzesunięcieKod operacjiStos
1DFJUMPDEST0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPValue* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDValue*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Value*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

Jeśli dotarliśmy tutaj, pobieramy Value* + CALLVALUE i skaczemy do przesunięcia 0x75.

PrzesunięcieKod operacjiStos
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Jeśli dotrzemy tutaj (co wymaga, aby dane wywołania były puste), dodajemy do Value* wartość wywołania. Jest to spójne z tym, co mówimy o działaniu transakcji Transfer.

PrzesunięcieKod operacji
79POP
7APOP
7BSTOP

Na koniec czyścimy stos (co nie jest konieczne) i sygnalizujemy pomyślne zakończenie transakcji.

Podsumowując, oto schemat blokowy dla początkowego kodu.

Entry point flowchart

Handler pod adresem 0x7C

Celowo nie umieściłem w nagłówku informacji o tym, co robi ten handler. Nie chodzi o to, aby nauczyć Cię, jak działa ten konkretny kontrakt, ale jak odtwarzać kod źródłowy kontraktów (inżynieria wsteczna). Dowiesz się, co on robi, w ten sam sposób co ja, śledząc kod.

Trafiamy tutaj z kilku miejsc:

  • Jeśli dane wywołania mają 1, 2 lub 3 bajty (od przesunięcia 0x63)
  • Jeśli podpis metody jest nieznany (od przesunięć 0x42 i 0x5D)
PrzesunięcieKod operacjiStos
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

To kolejna komórka pamięci (storage), której nie mogłem znaleźć w żadnych transakcjach, więc trudniej jest określić, co oznacza. Poniższy kod to rozjaśni.

PrzesunięcieKod operacjiStos
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-jako-adres 0x9D 0x00

Te kody operacji obcinają wartość odczytaną ze Storage[3] do 160 bitów, czyli długości adresu Ethereum.

PrzesunięcieKod operacjiStos
9BSWAP10x9D Storage[3]-jako-adres 0x00
9CJUMPStorage[3]-jako-adres 0x00

Ten skok jest zbędny, ponieważ i tak przechodzimy do następnego kodu operacji. Ten kod nie jest tak zoptymalizowany pod kątem zużycia gazu, jak mógłby być.

PrzesunięcieKod operacjiStos
9DJUMPDESTStorage[3]-jako-adres 0x00
9ESWAP10x00 Storage[3]-jako-adres
9FPOPStorage[3]-jako-adres
A0PUSH1 0x400x40 Storage[3]-jako-adres
A2MLOADMem[0x40] Storage[3]-jako-adres

Na samym początku kodu ustawiliśmy Mem[0x40] na 0x80. Jeśli poszukamy 0x40 później, zobaczymy, że go nie zmieniamy – możemy więc założyć, że wynosi 0x80.

PrzesunięcieKod operacjiStos
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-jako-adres
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-jako-adres
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-jako-adres
A7CALLDATACOPY0x80 Storage[3]-jako-adres

Kopiuje wszystkie dane wywołania do pamięci, zaczynając od 0x80.

PrzesunięcieKod operacjiStos
A8PUSH1 0x000x00 0x80 Storage[3]-jako-adres
AADUP10x00 0x00 0x80 Storage[3]-jako-adres
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adres
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adres
ADDUP6Storage[3]-jako-adres 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adres
AEGASGAS Storage[3]-jako-adres 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adres
AFDELEGATE_CALL

Teraz wszystko jest o wiele jaśniejsze. Ten kontrakt może działać jako kontrakt proxy (opens in a new tab), wywołując adres w Storage[3], aby wykonać właściwą pracę. DELEGATE_CALL wywołuje oddzielny kontrakt, ale pozostaje w tej samej pamięci (storage). Oznacza to, że delegowany kontrakt, dla którego jesteśmy proxy, ma dostęp do tej samej przestrzeni pamięci. Parametry wywołania to:

  • Gaz: Cały pozostały gaz
  • Wywoływany adres: Storage[3]-jako-adres
  • Dane wywołania: Bajty CALLDATASIZE zaczynające się od 0x80, czyli tam, gdzie umieściliśmy oryginalne dane wywołania
  • Dane zwrotne: Brak (0x00 - 0x00) Dane zwrotne uzyskamy w inny sposób (patrz poniżej)
PrzesunięcieKod operacjiStos
B0RETURNDATASIZERETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
B1DUP1RETURNDATASIZE RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
B5RETURNDATACOPYRETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres

Tutaj kopiujemy wszystkie dane zwrotne do bufora pamięci zaczynającego się od 0x80.

PrzesunięcieKod operacjiStos
B6DUP2(((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
B7DUP1(((sukces/niepowodzenie wywołania))) (((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
B8ISZERO(((czy wywołanie się nie powiodło))) (((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
B9PUSH2 0x00c00xC0 (((czy wywołanie się nie powiodło))) (((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
BCJUMPI(((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
BDDUP2RETURNDATASIZE (((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
BEDUP50x80 RETURNDATASIZE (((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
BFRETURN

Zatem po wywołaniu kopiujemy dane zwrotne do bufora 0x80 - 0x80+RETURNDATASIZE, a jeśli wywołanie zakończy się sukcesem, wykonujemy RETURN z dokładnie tym buforem.

Niepowodzenie DELEGATECALL

Jeśli dotrzemy tutaj, do 0xC0, oznacza to, że wywołany przez nas kontrakt został wycofany. Ponieważ jesteśmy tylko kontraktem proxy dla tego kontraktu, chcemy zwrócić te same dane i również wycofać transakcję.

PrzesunięcieKod operacjiStos
C0JUMPDEST(((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
C1DUP2RETURNDATASIZE (((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
C2DUP50x80 RETURNDATASIZE (((sukces/niepowodzenie wywołania))) RETURNDATASIZE (((sukces/niepowodzenie wywołania))) 0x80 Storage[3]-jako-adres
C3REVERT

Więc wykonujemy REVERT z tym samym buforem, którego użyliśmy wcześniej dla RETURN: 0x80 - 0x80+RETURNDATASIZE

Call to proxy flowchart

Wywołania ABI

Jeśli rozmiar danych wywołania wynosi cztery bajty lub więcej, może to być prawidłowe wywołanie ABI.

OffsetKod operacjiStos
DPUSH1 0x000x00
FCALLDATALOAD(((Pierwsze słowo (256 bitów) danych wywołania)))
10PUSH1 0xe00xE0 (((Pierwsze słowo (256 bitów) danych wywołania)))
12SHR(((pierwsze 32 bity (4 bajty) danych wywołania)))

Etherscan informuje nas, że 1C to nieznany kod operacji, ponieważ został dodany po tym, jak Etherscan napisał tę funkcję (opens in a new tab) i nie została ona zaktualizowana. Aktualna tabela kodów operacji (opens in a new tab) pokazuje nam, że jest to przesunięcie w prawo (shift right).

OffsetKod operacjiStos
13DUP1(((pierwsze 32 bity (4 bajty) danych wywołania))) (((pierwsze 32 bity (4 bajty) danych wywołania)))
14PUSH4 0x3cd8045e0x3CD8045E (((pierwsze 32 bity (4 bajty) danych wywołania))) (((pierwsze 32 bity (4 bajty) danych wywołania)))
19GT0x3CD8045E>pierwsze-32-bity-danych-wywolania (((pierwsze 32 bity (4 bajty) danych wywołania)))
1APUSH2 0x00430x43 0x3CD8045E>pierwsze-32-bity-danych-wywolania (((pierwsze 32 bity (4 bajty) danych wywołania)))
1DJUMPI(((pierwsze 32 bity (4 bajty) danych wywołania)))

Podział testów dopasowania podpisu metody na dwie części w ten sposób pozwala zaoszczędzić średnio połowę testów. Kod, który następuje bezpośrednio po tym, oraz kod w 0x43 podążają za tym samym wzorcem: DUP1 pierwsze 32 bity danych wywołania, PUSH4 (((method signature>, wykonuje EQ, aby sprawdzić równość, a następnie JUMPI, jeśli podpis metody się zgadza. Oto podpisy metod, ich adresy oraz, jeśli jest znana, odpowiadająca im definicja metody (opens in a new tab):

MetodaPodpis metodyPrzesunięcie do skoku
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Jeśli nie zostanie znalezione żadne dopasowanie, kod skacze do obsługi proxy pod adresem 0x7C w nadziei, że kontrakt, dla którego jesteśmy proxy, ma dopasowanie.

ABI calls flowchart

splitter()

OffsetKod operacjiStos
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

Pierwszą rzeczą, którą robi ta funkcja, jest sprawdzenie, czy wywołanie nie przesłało żadnego ETH. Ta funkcja nie jest payable (opens in a new tab). Jeśli ktoś wysłał nam ETH, musi to być błąd, a my chcemy wykonać REVERT, aby uniknąć zablokowania tego ETH bez możliwości jego odzyskania.

OffsetKod operacjiStos
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3], czyli kontrakt, dla którego jesteśmy proxy)))
114PUSH1 0x400x40 (((Storage[3], czyli kontrakt, dla którego jesteśmy proxy)))
116MLOAD0x80 (((Storage[3], czyli kontrakt, dla którego jesteśmy proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3], czyli kontrakt, dla którego jesteśmy proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3], czyli kontrakt, dla którego jesteśmy proxy)))
12DSWAP2(((Storage[3], czyli kontrakt, dla którego jesteśmy proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

A 0x80 zawiera teraz adres proxy

OffsetKod operacjiStos
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Kod E4

Widzimy te linie po raz pierwszy, ale są one współdzielone z innymi metodami (patrz poniżej). Nazwiemy więc wartość na stosie X i po prostu zapamiętamy, że w splitter() wartość tego X wynosi 0xA0.

OffsetKod operacjiStos
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Zatem ten kod otrzymuje wskaźnik pamięci na stosie (X) i powoduje, że kontrakt wykonuje RETURN z buforem wynoszącym 0x80 - X.

W przypadku splitter() zwraca to adres, dla którego jesteśmy proxy. RETURN zwraca bufor w zakresie 0x80-0x9F, czyli tam, gdzie zapisaliśmy te dane (offset 0x130 powyżej).

currentWindow()

Kod w offsetach 0x158-0x163 jest identyczny z tym, co widzieliśmy w 0x103-0x10E w splitter() (poza miejscem docelowym JUMPI), więc wiemy, że currentWindow() również nie jest payable.

OffsetKod operacjiStos
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

Kod DA

Ten kod jest również współdzielony z innymi metodami. Nazwiemy więc wartość na stosie Y i po prostu zapamiętamy, że w currentWindow() wartością tego Y jest Storage[1].

OffsetKod operacjiStos
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Zapisz Y w 0x80-0x9F.

OffsetKod operacjiStos
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

A reszta została już wyjaśniona powyżej. Zatem skoki do 0xDA zapisują szczyt stosu (Y) w 0x80-0x9F i zwracają tę wartość. W przypadku currentWindow() zwraca Storage[1].

merkleRoot()

Kod w offsetach 0xED-0xF8 jest identyczny z tym, co widzieliśmy w 0x103-0x10E w splitter() (poza miejscem docelowym JUMPI), więc wiemy, że merkleRoot() również nie jest payable.

OffsetKod operacjiStos
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Co dzieje się po skoku, już ustaliliśmy. Zatem merkleRoot() zwraca Storage[0].

0x81e580d3

Kod pod offsetami 0x138-0x143 jest identyczny z tym, co widzieliśmy pod 0x103-0x10E w splitter() (poza miejscem docelowym JUMPI), więc wiemy, że ta funkcja również nie jest payable.

OffsetKod operacjiStos
144JUMPDEST
145POP
146PUSH2 0x00da0xDA
149PUSH2 0x01530x0153 0xDA
14CCALLDATASIZECALLDATASIZE 0x0153 0xDA
14DPUSH1 0x040x04 CALLDATASIZE 0x0153 0xDA
14FPUSH2 0x018f0x018F 0x04 CALLDATASIZE 0x0153 0xDA
152JUMP0x04 CALLDATASIZE 0x0153 0xDA
18FJUMPDEST0x04 CALLDATASIZE 0x0153 0xDA
190PUSH1 0x000x00 0x04 CALLDATASIZE 0x0153 0xDA
192PUSH1 0x200x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
194DUP30x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
195DUP5CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
196SUBCALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA
197SLTCALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
198ISZEROCALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
199PUSH2 0x01a00x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19CJUMPI0x00 0x04 CALLDATASIZE 0x0153 0xDA

Wygląda na to, że ta funkcja przyjmuje co najmniej 32 bajty (jedno słowo) danych wywołania.

OffsetKod operacjiStos
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Jeśli nie otrzyma danych wywołania, transakcja zostaje wycofana bez żadnych zwracanych danych.

Zobaczmy, co się stanie, jeśli funkcja otrzyma potrzebne dane wywołania.

OffsetKod operacjiStos
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) to pierwsze słowo danych wywołania po sygnaturze metody

OffsetKod operacjiStos
1A3SWAP20x0153 CALLDATASIZE calldataload(4) 0xDA
1A4SWAP1CALLDATASIZE 0x0153 calldataload(4) 0xDA
1A5POP0x0153 calldataload(4) 0xDA
1A6JUMPcalldataload(4) 0xDA
153JUMPDESTcalldataload(4) 0xDA
154PUSH2 0x016e0x016E calldataload(4) 0xDA
157JUMPcalldataload(4) 0xDA
16EJUMPDESTcalldataload(4) 0xDA
16FPUSH1 0x040x04 calldataload(4) 0xDA
171DUP2calldataload(4) 0x04 calldataload(4) 0xDA
172DUP20x04 calldataload(4) 0x04 calldataload(4) 0xDA
173SLOADStorage[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(4) 0x04 calldataload(4) 0xDA

Jeśli pierwsze słowo nie jest mniejsze niż Storage[4], funkcja kończy się niepowodzeniem. Zostaje wycofana bez żadnej zwracanej wartości:

OffsetKod operacjiStos
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Jeśli calldataload(4) jest mniejsze niż Storage[4], otrzymujemy ten kod:

OffsetKod operacjiStos
17EJUMPDESTcalldataload(4) 0x04 calldataload(4) 0xDA
17FPUSH1 0x000x00 calldataload(4) 0x04 calldataload(4) 0xDA
181SWAP20x04 calldataload(4) 0x00 calldataload(4) 0xDA
182DUP30x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA
183MSTOREcalldataload(4) 0x00 calldataload(4) 0xDA

A lokacje pamięci 0x00-0x1F zawierają teraz dane 0x04 (0x00-0x1E to same zera, 0x1F to cztery)

OffsetKod operacjiStos
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 of 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 of 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

Zatem w pamięci (storage) znajduje się tabela wyszukiwania, która zaczyna się od SHA3 z 0x000...0004 i ma wpis dla każdej prawidłowej wartości danych wywołania (wartość poniżej Storage[4]).

OffsetKod operacjiStos
18BSWAP1calldataload(4) Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStorage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA

Wiemy już, co robi kod pod offsetem 0xDA, zwraca on wartość ze szczytu stosu do wywołującego. Zatem ta funkcja zwraca wywołującemu wartość z tabeli wyszukiwania.

0x1f135823

Kod w offsetach 0xC4-0xCF jest identyczny z tym, co widzieliśmy w 0x103-0x10E w splitter() (poza miejscem docelowym JUMPI), więc wiemy, że ta funkcja również nie jest payable.

OffsetKod operacjiStos
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

Wiemy już, co robi kod pod offsetem 0xDA, zwraca on wartość ze szczytu stosu do wywołującego. Więc ta funkcja zwraca Value*.

Podsumowanie metod

Czy na tym etapie czujesz, że rozumiesz ten kontrakt? Ja nie. Jak dotąd mamy następujące metody:

MetodaZnaczenie
TransferAkceptuje wartość przekazaną w wywołaniu i zwiększa Value* o tę kwotę
splitter()Zwraca Storage[3], adres proxy
currentWindow()Zwraca Storage[1]
merkleRoot()Zwraca Storage[0]
0x81e580d3Zwraca wartość z tabeli wyszukiwania, pod warunkiem, że parametr jest mniejszy niż Storage[4]
0x1f135823Zwraca Storage[6], czyli Value*

Wiemy jednak, że wszelka inna funkcjonalność jest dostarczana przez kontrakt w Storage[3]. Może gdybyśmy wiedzieli, czym jest ten kontrakt, dałoby nam to jakąś wskazówkę. Na szczęście to jest blockchain i wszystko jest wiadome, przynajmniej w teorii. Nie widzieliśmy żadnych metod, które ustawiałyby Storage[3], więc musiało to zostać ustawione przez konstruktor.

Konstruktor

Kiedy przyglądamy się kontraktowi (opens in a new tab), możemy również zobaczyć transakcję, która go utworzyła.

Click the create transaction

Jeśli klikniemy tę transakcję, a następnie zakładkę Stan, możemy zobaczyć początkowe wartości parametrów. W szczególności możemy zauważyć, że Storage[3] zawiera 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Ten kontrakt musi zawierać brakującą funkcjonalność. Możemy go zrozumieć, używając tych samych narzędzi, których użyliśmy do badanego przez nas kontraktu.

Kontrakt proxy

Używając tych samych technik, których użyliśmy dla oryginalnego kontraktu powyżej, możemy zauważyć, że kontrakt zostaje wycofany, jeśli:

  • Do wywołania dołączone jest jakiekolwiek ETH (0x05-0x0F)
  • Rozmiar danych wywołania jest mniejszy niż cztery (0x10-0x19 i 0xBE-0xC2)

Oraz że obsługiwane przez niego metody to:

Możemy zignorować cztery ostatnie metody, ponieważ nigdy do nich nie dotrzemy. Ich podpisy są takie, że nasz oryginalny kontrakt sam się nimi zajmuje (możesz kliknąć podpisy, aby zobaczyć szczegóły powyżej), więc muszą to być metody, które zostały nadpisane (opens in a new tab).

Jedną z pozostałych metod jest claim(<params>), a kolejną isClaimed(<params>), więc wygląda to na kontrakt airdropu. Zamiast przechodzić przez resztę kod operacji po kodzie operacji, możemy wypróbować dekompilator (opens in a new tab), który generuje użyteczne wyniki dla trzech funkcji z tego kontraktu. Inżynieria wsteczna pozostałych pozostaje ćwiczeniem dla czytelnika.

scaleAmountByPercentage

Oto co dekompilator zwraca dla tej funkcji:

def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
  require calldata.size - 4 >=64
  if _param1 and _param2 > -1 / _param1:
      revert with 0, 17
  return (_param1 * _param2 / 100 * 10^6)

Pierwsze require sprawdza, czy dane wywołania mają, oprócz czterech bajtów podpisu funkcji, co najmniej 64 bajty, co wystarcza na dwa parametry. Jeśli nie, to oczywiście coś jest nie tak.

Instrukcja if wydaje się sprawdzać, czy _param1 nie jest zerem, a _param1 * _param2 nie jest ujemne. Prawdopodobnie ma to na celu zapobieganie przypadkom przepełnienia (wrap around).

Na koniec funkcja zwraca przeskalowaną wartość.

claim

Kod generowany przez dekompilator jest złożony i nie cały jest dla nas istotny. Pominę jego część, aby skupić się na wierszach, które moim zdaniem dostarczają przydatnych informacji.

def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
  ...
  require _param2 == addr(_param2)
  ...
  if currentWindow <= _param1:
      revert with 0, 'cannot claim for a future window'

Widzimy tutaj dwie ważne rzeczy:

  • _param2, chociaż jest zadeklarowane jako uint256, w rzeczywistości jest adresem
  • _param1 to okno, z którego odbierane są środki, które musi być currentWindow lub wcześniejsze.
  ...
  if stor5[_claimWindow][addr(_claimFor)]:
      revert with 0, 'Account already claimed the given window'

Więc teraz wiemy, że Storage[5] to tablica okien i adresów, oraz informacja, czy dany adres odebrał nagrodę dla tego okna.

Wiemy, że unknown2eb4a7ab to w rzeczywistości funkcja merkleRoot(), więc ten kod wygląda, jakby weryfikował dowód Merkle'a (opens in a new tab). Oznacza to, że _param4 jest dowodem Merkle'a.

  call addr(_param2) with:
     value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei
       gas 30000 wei

W ten sposób kontrakt transferuje własne ETH na inny adres (kontraktu lub posiadany zewnętrznie). Wywołuje go z wartością, która jest kwotą do przetransferowania. Wygląda więc na to, że jest to airdrop ETH.

  if not return_data.size:
      if not ext_call.success:
          require ext_code.size(stor2)
          call stor2.deposit() with:
             value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei

Dwie ostatnie linie mówią nam, że Storage[2] to również kontrakt, który wywołujemy. Jeśli spojrzymy na transakcję konstruktora (opens in a new tab), zobaczymy, że ten kontrakt to 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), kontrakt opakowanego etheru (WETH), którego kod źródłowy został przesłany do Etherscan (opens in a new tab).

Wygląda więc na to, że kontrakt próbuje wysłać ETH do _param2. Jeśli mu się to uda, to świetnie. Jeśli nie, próbuje wysłać WETH (opens in a new tab). Jeśli _param2 jest kontem posiadanym zewnętrznie (EOA), to zawsze może otrzymać ETH, ale kontrakty mogą odmówić przyjęcia ETH. Jednak WETH to ERC-20 i kontrakty nie mogą odmówić jego przyjęcia.

  ...
  log 0xdbd5389f: addr(_param2), unknown81e580d3[_param1] * _param3 / 100 * 10^6, bool(ext_call.success)

Na końcu funkcji widzimy, że generowany jest wpis logu. Spójrz na wygenerowane wpisy logów (opens in a new tab) i przefiltruj po temacie, który zaczyna się od 0xdbd5.... Jeśli klikniemy jedną z transakcji, która wygenerowała taki wpis (opens in a new tab), zobaczymy, że rzeczywiście wygląda to na odebranie środków (claim) - konto wysłało wiadomość do kontraktu, który poddajemy inżynierii wstecznej, a w zamian otrzymało ETH.

A claim transaction

1e7df9d3

Ta funkcja jest bardzo podobna do claim powyżej. Również sprawdza dowód Merkle'a, próbuje przetransferować ETH do pierwszego i generuje ten sam typ wpisu logu.

Główna różnica polega na tym, że nie ma pierwszego parametru, czyli okna do wypłaty. Zamiast tego występuje pętla po wszystkich oknach, z których można odebrać środki.

Wygląda to więc na wariant claim, który odbiera środki ze wszystkich okien.

Podsumowanie

Teraz powinieneś już wiedzieć, jak zrozumieć kontrakty, których kod źródłowy nie jest dostępny, używając kodów operacji lub (gdy to działa) dekompilatora. Jak widać po długości tego artykułu, inżynieria wsteczna kontraktu nie jest trywialna, ale w systemie, w którym bezpieczeństwo jest kluczowe, ważną umiejętnością jest możliwość weryfikacji, czy kontrakty działają zgodnie z obietnicą.

Więcej moich prac znajdziesz tutaj (opens in a new tab).