Przejdź do głównej zawartości

Inżynieria odwrotna kontraktu

evm
kody operacyjne
Zaawansowane funkcje
Ori Pomerantz
30 grudnia 2021
30 minuta czytania

Wprowadzenie

W blockchainie nie ma tajemnic, wszystko, co się dzieje, jest spójne, weryfikowalne i publicznie dostępne. kontrakty powinny mieć opublikowany i zweryfikowany kod źródłowy na Etherscanopens in a new tab. Nie zawsze tak jestopens in a new tab. W tym artykule dowiesz się, jak przeprowadzać inżynierię wsteczną kontraktów, przyglądając się kontraktowi bez kodu źródłowego, 0x2510c039cc3b061d79e564b38836da87e31b342fopens in a new tab.

Istnieją odwrotne kompilatory, ale nie zawsze dają one użyteczne wynikiopens in a new tab. W tym artykule dowiesz się, jak ręcznie przeprowadzić inżynierię wsteczną i zrozumieć kontrakt z kodów operacyjnychopens in a new tab, a także jak interpretować wyniki dekompilatora.

Aby zrozumieć ten artykuł, powinieneś już znać podstawy EVM i być przynajmniej w pewnym stopniu zaznajomiony z asemblerem EVM. Możesz przeczytać o tych tematach tutajopens in a new tab.

Przygotuj kod wykonywalny

Kody operacyjne można uzyskać, przechodząc do Etherscan dla kontraktu, klikając kartę Kontrakt, a następnie Przełącz na widok kodów operacyjnych. Otrzymasz widok, w którym w każdej linii znajduje się jeden kod operacyjny.

Widok kodów operacyjnych z Etherscan

Aby jednak zrozumieć skoki, musisz wiedzieć, gdzie w kodzie znajduje się każdy kod operacyjny. Aby to zrobić, jednym ze sposobów jest otwarcie Arkusza Google i wklejenie kodów operacyjnych w kolumnie C. Możesz pominąć następujące kroki, tworząc kopię tego już przygotowanego arkusza kalkulacyjnegoopens in a new tab.

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

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

Najpierw ta funkcja dodaje jeden bajt dla samego kodu operacyjnego, a następnie szuka PUSH. Kody operacyjne typu „push” są specjalne, ponieważ potrzebują dodatkowych bajtów dla przesyłanej wartości. Jeśli kod operacyjny to PUSH, wyodrębniamy liczbę bajtów i dodajemy ją.

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

1=dec2hex(hex2dec(A1)+B1)

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

Punkt wejścia (0x00)

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

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

Ten kod robi dwie rzeczy:

  1. Zapisz 0x80 jako 32-bajtową wartość w lokalizacjach pamięci 0x40-0x5F (0x80 jest przechowywane w 0x5F, a 0x40-0x5E to same zera).
  2. Odczytaj rozmiar calldata. Zwykle dane wywołania dla kontraktu Ethereum są zgodne z ABI (binarny interfejs aplikacji)opens in a new tab, który wymaga co najmniej czterech bajtów dla selektora funkcji. Jeśli rozmiar danych wywołania jest mniejszy niż cztery, przejdź do 0x5E.

Schemat blokowy dla tej części

Procedura obsługi w 0x5E (dla danych wywołania innych niż ABI)

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

Ten fragment zaczyna się od JUMPDEST. Programy EVM (Wirtualna Maszyna Ethereum) zgłaszają wyjątek, jeśli przeskoczysz do kodu operacyjnego, który nie jest JUMPDEST. Następnie sprawdza CALLDATASIZE i jeśli jest „prawdziwe” (to znaczy nie jest zerem), przeskakuje do 0x7C. Do tego przejdziemy poniżej.

PrzesunięcieKod operacyjnyStos (po kodzie operacyjnym)
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]. Nie wiemy jeszcze, jaka to wartość, ale możemy poszukać transakcji, które kontrakt otrzymał bez danych wywołania. Transakcje, które po prostu przesyłają ETH bez żadnych danych wywołania (a zatem bez metody), mają w Etherscan metodę Transfer. W rzeczywistości pierwsza transakcja, jaką otrzymał kontraktopens in a new tab jest transferem.

Jeśli spojrzymy na tę transakcję i klikniemy Kliknij, aby zobaczyć więcej, zobaczymy, że dane wywołania, zwane danymi wejściowymi, są rzeczywiście puste (0x). Zauważ również, że wartość wynosi 1,559 ETH, co będzie istotne później.

Dane wywołania są puste

Następnie kliknij kartę Stan i rozwiń kontrakt, którego inżynierię odwrotną przeprowadzamy (0x2510...). Można zauważyć, że Storage[6] zmieniło się podczas transakcji, a jeśli zmienisz Hex na Liczba, zobaczysz, że stało się to 1,559,000,000,000,000,000, wartość przeniesiona w wei (dodałem przecinki dla przejrzystości), co odpowiada następnej wartości kontraktu.

Zmiana w Storage[6]

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

PrzesunięcieKod operacyjnyStos
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 operacyjnyStos
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 operacyjnyStos
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. I rzeczywiście, widzimy, że po kilku bezsensownych operacjach (na przykład zapis do pamięci, który zaraz zostanie usunięty) przy przesunięciu 0x01DE kontrakt powraca, jeśli wykryte zostanie przepełnienie, co jest normalnym zachowaniem.

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

PrzesunięcieKod operacyjnyStos
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, pobierz Value* + CALLVALUE i przejdź do przesunięcia 0x75.

PrzesunięcieKod operacyjnyStos
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 zgodne z tym, co według nas robią transakcje Transfer.

PrzesunięcieKod operacyjny
79POP
7APOP
7BSTOP

Na koniec wyczyść stos (co nie jest konieczne) i zasygnalizuj pomyślne zakończenie transakcji.

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

Schemat blokowy punktu wejścia

Procedura obsługi w 0x7C

Celowo nie umieściłem w nagłówku informacji o tym, co robi ta procedura obsługi. Celem nie jest nauczenie Cię, jak działa ten konkretny kontrakt, ale jak przeprowadzać inżynierię wsteczną kontraktów. Dowiesz się, co robi, w ten sam sposób, co ja – śledząc kod.

Docieramy tu z kilku miejsc:

  • Jeśli istnieją dane wywołania o długości 1, 2 lub 3 bajtów (z przesunięcia 0x63)
  • Jeśli sygnatura metody jest nieznana (z przesunięć 0x42 i 0x5D)
PrzesunięcieKod operacyjnyStos
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

To jest kolejna komórka pamięci, której nie mogłem znaleźć w żadnej transakcji, więc trudniej jest dowiedzieć się, co ona oznacza. Poniższy kod wyjaśni to.

PrzesunięcieKod operacyjnyStos
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

Te kody operacyjne obcinają wartość, którą odczytujemy z Storage[3] do 160 bitów, czyli długości adresu Ethereum.

PrzesunięcieKod operacyjnyStos
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

Ten skok jest zbędny, ponieważ przechodzimy do następnego kodu operacyjnego. Ten kod nie jest tak wydajny pod względem zużycia gazu, jak mógłby być.

PrzesunięcieKod operacyjnyStos
9DJUMPDESTStorage[3]-as-address 0x00
9ESWAP10x00 Storage[3]-as-address
9FPOPStorage[3]-as-address
A0PUSH1 0x400x40 Storage[3]-as-address
A2MLOADMem[0x40] Storage[3]-as-address

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

PrzesunięcieKod operacyjnyStos
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-as-address
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-as-address
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address
A7CALLDATACOPY0x80 Storage[3]-as-address

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

PrzesunięcieKod operacyjnyStos
A8PUSH1 0x000x00 0x80 Storage[3]-as-address
AADUP10x00 0x00 0x80 Storage[3]-as-address
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
ADDUP6Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AEGASGAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address
AFDELEGATE_CALL

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

  • Gaz: Cały pozostały gaz
  • Wywołany adres: Storage[3]-as-address
  • Dane wywołania: Bajty CALLDATASIZE zaczynające się od 0x80, gdzie umieściliśmy oryginalne dane wywołania
  • Dane zwrotne: Brak (0x00 - 0x00) Dane zwrotne uzyskamy w inny sposób (patrz niżej)
PrzesunięcieKod operacyjnyStos
B0RETURNDATASIZERETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address

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

PrzesunięcieKod operacyjnyStos
B6DUP2(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B7DUP1(((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B8ISZERO(((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BCJUMPI(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BFRETURN

Więc po wywołaniu kopiujemy dane zwrotne do bufora 0x80 - 0x80+RETURNDATASIZE, a jeśli wywołanie się powiedzie, to RETURN z dokładnie tym buforem.

DELEGATECALL Nie powiodło się

Jeśli dotrzemy tutaj, do 0xC0, oznacza to, że wywołany kontrakt został wycofany. Ponieważ jesteśmy tylko proxy dla tego kontraktu, chcemy zwrócić te same dane, a także cofnąć operację.

PrzesunięcieKod operacyjnyStos
C0JUMPDEST(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C3REVERT

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

Schemat blokowy wywołania do proxy

Wywołania ABI

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

PrzesunięcieKod operacyjnyStos
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 operacyjny, ponieważ został dodany po tym, jak Etherscan napisał tę funkcjęopens in a new tab, a oni go nie zaktualizowali. Aktualna tabela kodów operacyjnychopens in a new tab pokazuje, że jest to przesunięcie w prawo

PrzesunięcieKod operacyjnyStos
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-wywołania (((pierwsze 32 bity (4 bajty) danych wywołania)))
1APUSH2 0x00430x43 0x3CD8045E>pierwsze-32-bity-danych-wywołania (((pierwsze 32 bity (4 bajty) danych wywołania)))
1DJUMPI(((pierwsze 32 bity (4 bajty) danych wywołania)))

Podzielenie testów dopasowania sygnatury metody na dwie części w ten sposób oszczędza średnio połowę testów. Kod, który bezpośrednio następuje po tym, oraz kod w 0x43, mają ten sam wzorzec: DUP1 pierwszych 32 bitów danych wywołania, PUSH4 (((sygnatura metody>, uruchom EQ, aby sprawdzić równość, a następnie JUMPI, jeśli sygnatura metody pasuje. Oto sygnatury metod, ich adresy oraz, jeśli jest znana, odpowiadająca im definicja metodyopens in a new tab:

MetodaSygnatura metodyPrzesunięcie do skoku
splitter()opens in a new tab0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()opens in a new tab0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()opens in a new tab0x2eb4a7ab0x00ED

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

Schemat blokowy wywołań ABI

splitter()

PrzesunięcieKod operacyjnyStos
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

Pierwszą rzeczą, jaką robi ta funkcja, jest sprawdzenie, czy wywołanie nie wysłało żadnego ETH. Ta funkcja nie jest płatnaopens in a new tab. Jeśli ktoś wysłał nam ETH, to musi być pomyłka i chcemy REVERT, aby uniknąć posiadania tego ETH tam, gdzie nie mogą go odzyskać.

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

A 0x80 zawiera teraz adres proxy

PrzesunięcieKod operacyjnyStos
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Kod E4

Po raz pierwszy widzimy te linie, ale są one współdzielone z innymi metodami (patrz niżej). Więc nazwiemy wartość na stosie X i po prostu zapamiętamy, że w splitter() wartość tego X to 0xA0.

PrzesunięcieKod operacyjnyStos
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Tak więc ten kod otrzymuje wskaźnik pamięci na stosie (X) i powoduje, że kontrakt RETURN z buforem, który wynosi 0x80 - X.

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

currentWindow()

Kod w przesunięciach 0x158-0x163 jest identyczny z tym, który widzieliśmy w 0x103-0x10E w splitter() (inny niż miejsce docelowe JUMPI), więc wiemy, że currentWindow() również nie jest płatne.

PrzesunięcieKod operacyjnyStos
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. Więc nazwiemy wartość na stosie Y i po prostu zapamiętamy, że w currentWindow() wartość tego Y to Storage[1].

PrzesunięcieKod operacyjnyStos
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Zapisz Y do 0x80-0x9F.

PrzesunięcieKod operacyjnyStos
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

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

merkleRoot()

Kod w przesunięciach 0xED-0xF8 jest identyczny z tym, który widzieliśmy w 0x103-0x10E w splitter() (inny niż miejsce docelowe JUMPI), więc wiemy, że merkleRoot() również nie jest płatny.

PrzesunięcieKod operacyjnyStos
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. Więc merkleRoot() zwraca Storage[0].

0x81e580d3

Kod w przesunięciach 0x138-0x143 jest identyczny z tym, który widzieliśmy w 0x103-0x10E w splitter() (inny niż miejsce docelowe JUMPI), więc wiemy, że ta funkcja również nie jest płatna.

PrzesunięcieKod operacyjnyStos
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.

PrzesunięcieKod operacyjnyStos
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

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

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

PrzesunięcieKod operacyjnyStos
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

PrzesunięcieKod operacyjnyStos
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. Wycofuje się bez żadnej zwróconej wartości:

PrzesunięcieKod operacyjnyStos
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

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

PrzesunięcieKod operacyjnyStos
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 lokalizacje pamięci 0x00-0x1F zawierają teraz dane 0x04 (0x00-0x1E to same zera, 0x1F to cztery)

PrzesunięcieKod operacyjnyStos
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 z 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 z 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStorage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

Więc w pamięci masowej (storage) znajduje się tabela przeglądowa, która zaczyna się od SHA3 z 0x000...0004 i ma wpis dla każdej legalnej wartości danych wywołania (wartość poniżej Storage[4]).

PrzesunięcieKod operacyjnyStos
18BSWAP1calldataload(4) Storage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStorage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Storage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStorage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] 0xDA

Wiemy już, co robi kod przy przesunięciu 0xDA, zwraca on wartość szczytu stosu do wywołującego. Więc ta funkcja zwraca wartość z tabeli przeglądowej do wywołującego.

0x1f135823

Kod w przesunięciach 0xC4-0xCF jest identyczny z tym, który widzieliśmy w 0x103-0x10E w splitter() (inny niż miejsce docelowe JUMPI), więc wiemy, że ta funkcja również nie jest płatna.

PrzesunięcieKod operacyjnyStos
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

Wiemy już, co robi kod przy przesunięciu 0xDA, zwraca on wartość szczytu stosu do wywołującego. Tak więc ta funkcja zwraca Value*.

Podsumowanie metody

Czy czujesz, że rozumiesz kontrakt w tym momencie? Ja nie. Do tej pory mamy te metody:

MetodaZnaczenie
PrzelewZaakceptuj wartość podaną przez wywołanie i zwiększ Value* o tę kwotę
splitter()Zwróć Storage[3], adres proxy
currentWindow()Zwróć Storage[1]
merkleRoot()Zwróć Storage[0]
0x81e580d3Zwróć wartość z tabeli przeglądowej, pod warunkiem że parametr jest mniejszy niż Storage[4]
0x1f135823Zwróć Storage[6], inaczej Wartość*

Ale wiemy, że każda 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 znane, przynajmniej w teorii. Nie widzieliśmy żadnych metod, które ustawiają Storage[3], więc musiało to zostać ustawione przez konstruktor.

Konstruktor

Kiedy patrzymy na kontraktopens in a new tab, możemy również zobaczyć transakcję, która go stworzyła.

Kliknij transakcję tworzenia

Jeśli klikniemy tę transakcję, a następnie kartę Stan, możemy zobaczyć początkowe wartości parametrów. W szczególności możemy zobaczyć, że Storage[3] zawiera 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761opens 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 badania kontraktu.

Kontrakt proxy

Używając tych samych technik, co w przypadku oryginalnego kontraktu powyżej, możemy zobaczyć, że kontrakt cofa się, 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 metody, które obsługuje to:

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

Jedną z pozostałych metod jest claim(<params>), a drugą isClaimed(<params>), więc wygląda na to, że jest to kontrakt airdropu. Zamiast przechodzić przez resztę kod po kodzie, możemy wypróbować dekompilatoropens in a new tab, który daje użyteczne wyniki dla trzech funkcji z tego kontraktu. Inżynieria odwrotna pozostałych jest pozostawiona jako ćwiczenie dla czytelnika.

scaleAmountByPercentage

Oto, co dekompilator daje nam dla tej funkcji:

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

Pierwsze require sprawdza, czy dane wywołania mają, oprócz czterech bajtów sygnatury funkcji, co najmniej 64 bajty, wystarczające dla dwóch parametrów. Jeśli nie, to oczywiście coś jest nie tak.

Instrukcja if wydaje się sprawdzać, czy _param1 nie jest zerem i czy _param1 * _param2 nie jest ujemne. Prawdopodobnie ma to na celu zapobieżenie przypadkom zawijania.

Na koniec funkcja zwraca przeskalowaną wartość.

claim

Kod, który tworzy dekompilator, jest złożony i nie cały jest dla nas istotny. Pominę niektóre z nich, aby skupić się na liniach, które moim zdaniem dostarczają przydatnych informacji

1def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
2 ...
3 require _param2 == addr(_param2)
4 ...
5 if currentWindow <= _param1:
6 revert with 0, 'nie można odebrać nagrody za przyszłe okno'

Widzimy tutaj dwie ważne rzeczy:

  • _param2, chociaż jest zadeklarowany jako uint256, jest w rzeczywistości adresem
  • _param1 to okno, za które odbierana jest nagroda, które musi być currentWindow lub wcześniejsze.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Konto już odebrało nagrodę za dane okno'

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

1 idx = 0
2 s = 0
3 while idx < _param4.length:
4 ...
5 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
6 mem[mem[64] + 32] = mem[(32 * idx) + 296]
7 ...
8 s = sha3(mem[_62 + 32 len mem[_62]])
9 continue
10 ...
11 s = sha3(mem[_66 + 32 len mem[_66]])
12 continue
13 if unknown2eb4a7ab != s:
14 revert with 0, 'Nieprawidłowy dowód'
Pokaż wszystko

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

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

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

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

Dwie dolne linie mówią nam, że Storage[2] to również kontrakt, który wywołujemy. Jeśli spojrzymy na transakcję konstruktoraopens in a new tab, zobaczymy, że ten kontrakt to 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2opens in a new tab, kontrakt Wrapped Ether, którego kod źródłowy został przesłany do Etherscanopens in a new tab.

Wygląda więc na to, że kontrakty próbują wysłać ETH do _param2. Jeśli może to zrobić, świetnie. Jeśli nie, próbuje wysłać WETHopens in a new tab. Jeśli _param2 to konto zewnętrzne (EOA), zawsze może ono otrzymywać ETH, ale kontrakty mogą odmawiać przyjmowania ETH. Jednak WETH jest ERC-20 i kontrakty nie mogą odmówić jego przyjęcia.

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

Na końcu funkcji widzimy, że generowany jest wpis w dzienniku. Spójrz na wygenerowane wpisy w dziennikuopens in a new tab i przefiltruj według tematu, który zaczyna się od 0xdbd5.... Jeśli klikniemy jedną z transakcji, która wygenerowała taki wpisopens in a new tab, zobaczymy, że rzeczywiście wygląda to na odebranie nagrody – konto wysłało wiadomość do kontraktu, którego inżynierię odwrotną przeprowadzamy, a w zamian otrzymało ETH.

Transakcja odebrania nagrody

1e7df9d3

Ta funkcja jest bardzo podobna do claim powyżej. Sprawdza również dowód Merkle'a, próbuje przenieść ETH do pierwszego i tworzy ten sam typ wpisu w dzienniku.

1def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
2 ...
3 idx = 0
4 s = 0
5 while idx < _param3.length:
6 if idx >= mem[96]:
7 revert with 0, 50
8 _55 = mem[(32 * idx) + 128]
9 if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
10 ...
11 s = sha3(mem[_58 + 32 len mem[_58]])
12 continue
13 mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
14 ...
15 if unknown2eb4a7ab != s:
16 revert with 0, 'Nieprawidłowy dowód'
17 ...
18 call addr(_param1) with:
19 value s wei
20 gas 30000 wei
21 if not return_data.size:
22 if not ext_call.success:
23 require ext_code.size(stor2)
24 call stor2.deposit() with:
25 value s wei
26 gas gas_remaining wei
27 ...
28 log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
Pokaż wszystko

Główna różnica polega na tym, że nie ma tam pierwszego parametru, czyli okna do wypłaty. Zamiast tego istnieje pętla obejmująca wszystkie okna, za które można odebrać nagrodę.

1 idx = 0
2 s = 0
3 while idx < currentWindow:
4 ...
5 if stor5[mem[0]]:
6 if idx == -1:
7 revert with 0, 17
8 idx = idx + 1
9 s = s
10 continue
11 ...
12 stor5[idx][addr(_param1)] = 1
13 if idx >= unknown81e580d3.length:
14 revert with 0, 50
15 mem[0] = 4
16 if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
17 revert with 0, 17
18 if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
19 revert with 0, 17
20 if idx == -1:
21 revert with 0, 17
22 idx = idx + 1
23 s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
24 continue
Pokaż wszystko

Wygląda więc na wariant claim, który odbiera nagrody za wszystkie okna.

Wnioski

Teraz powinieneś już wiedzieć, jak rozumieć kontrakty, których kod źródłowy nie jest dostępny, używając albo kodów operacyjnych, albo (gdy to działa) dekompilatora. Jak widać po długości tego artykułu, inżynieria odwrotna 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ą.

Zobacz więcej mojej pracy tutajopens in a new tab.

Strona ostatnio zaktualizowana: 14 lutego 2026

Czy ten samouczek był pomocny?