Přejít na hlavní obsah

Reverzní inženýrství kontraktu

evm
operační kódy
Pokročilý
Ori Pomerantz
30. prosince 2021
30 minut čtení

Úvod

Na blockchainu nejsou žádná tajemství, vše, co se děje, je konzistentní, ověřitelné a veřejně dostupné. Ideálně by kontrakty měly mít svůj zdrojový kód zveřejněný a ověřený na Etherscanu (opens in a new tab). Nicméně, ne vždy tomu tak je (opens in a new tab). V tomto článku se naučíte, jak provádět reverzní inženýrství kontraktů tím, že se podíváte na kontrakt bez zdrojového kódu, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Existují dekompilátory, ale ne vždy produkují použitelné výsledky (opens in a new tab). V tomto článku se naučíte, jak manuálně provést reverzní inženýrství a porozumět kontraktu z operačních kódů (opens in a new tab), a také jak interpretovat výsledky dekompilátoru.

Abyste tomuto článku porozuměli, měli byste již znát základy EVM a být alespoň trochu obeznámeni s assemblerem EVM. O těchto tématech si můžete přečíst zde (opens in a new tab).

Příprava spustitelného kódu

Operační kódy můžete získat tak, že přejdete na Etherscan daného kontraktu, kliknete na záložku Contract a poté na Switch to Opcodes View. Získáte zobrazení, kde je jeden operační kód na řádek.

Opcode View from Etherscan

Abyste však porozuměli skokům, musíte vědět, kde se v kódu každý operační kód nachází. Jedním ze způsobů, jak toho dosáhnout, je otevřít Tabulky Google a vložit operační kódy do sloupce C. Následující kroky můžete přeskočit vytvořením kopie této již připravené tabulky (opens in a new tab).

Dalším krokem je získat správná umístění kódu, abychom mohli porozumět skokům. Velikost operačního kódu vložíme do sloupce B a umístění (v šestnáctkové soustavě) do sloupce A. Napište tuto funkci do buňky B1 a poté ji zkopírujte a vložte do zbytku sloupce B až do konce kódu. Poté můžete sloupec B skrýt.

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

Tato funkce nejprve přidá jeden bajt pro samotný operační kód a poté hledá PUSH. Operační kódy PUSH jsou speciální, protože potřebují další bajty pro vkládanou hodnotu. Pokud je operačním kódem PUSH, extrahujeme počet bajtů a přičteme je.

Do A1 vložte první offset, nulu. Poté do A2 vložte tuto funkci a opět ji zkopírujte a vložte do zbytku sloupce A:

=dec2hex(hex2dec(A1)+B1)

Tuto funkci potřebujeme, aby nám poskytla hexadecimální hodnotu, protože hodnoty, které jsou vkládány před skoky (JUMP a JUMPI), jsou nám dány v hexadecimálním formátu.

Vstupní bod (0x00)

Kontrakty se vždy spouštějí od prvního bajtu. Toto je počáteční část kódu:

OffsetOperační kódZásobník (po operačním kódu)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREPrázdný
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIPrázdný

Tento kód dělá dvě věci:

  1. Zapíše 0x80 jako 32bajtovou hodnotu na paměťová místa 0x40-0x5F (0x80 je uloženo v 0x5F a 0x40-0x5E jsou samé nuly).
  2. Přečte velikost dat volání. Normálně se data volání pro Ethereum kontrakt řídí ABI (aplikačním binárním rozhraním) (opens in a new tab), které vyžaduje minimálně čtyři bajty pro selektor funkce. Pokud je velikost dat volání menší než čtyři, skočí na 0x5E.

Flowchart for this portion

Obslužná rutina na 0x5E (pro data volání mimo ABI)

OffsetOperační kód
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Tento úryvek začíná pomocí JUMPDEST. Programy EVM (Ethereum virtuální stroj) vyhodí výjimku, pokud skočíte na operační kód, který není JUMPDEST. Poté se podívá na CALLDATASIZE, a pokud je „pravda“ (tedy není nula), skočí na 0x7C. K tomu se dostaneme níže.

OffsetOperační kódZásobník (po operačním kódu)
64CALLVALUE poskytnuté voláním. V Solidity se nazývá msg.value
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Takže když neexistují žádná data volání, přečteme hodnotu Storage[6]. Zatím nevíme, co tato hodnota znamená, ale můžeme se podívat na transakce, které kontrakt přijal bez dat volání. Transakce, které pouze převádějí ETH bez jakýchkoli dat volání (a tedy bez metody), mají v Etherscanu metodu Transfer. Ve skutečnosti je úplně první transakce, kterou kontrakt přijal (opens in a new tab), převod.

Pokud se podíváme na tuto transakci a klikneme na Click to see More, uvidíme, že data volání, nazývaná vstupní data (input data), jsou skutečně prázdná (0x). Všimněte si také, že hodnota je 1.559 ETH, což bude důležité později.

The call data is empty

Dále klikněte na záložku State a rozbalte kontrakt, který zpětně analyzujeme (0x2510...). Můžete vidět, že Storage[6] se během transakce skutečně změnil, a pokud změníte Hex na Number, uvidíte, že se z něj stalo 1,559,000,000,000,000,000, což je převedená hodnota ve wei (čárky jsem přidal pro přehlednost), odpovídající další hodnotě kontraktu.

Změna ve Storage[6]

Pokud se podíváme na změny stavu způsobené dalšími Transfer transakcemi ze stejného období (opens in a new tab), uvidíme, že Storage[6] nějakou dobu sledoval hodnotu kontraktu. Prozatím to budeme nazývat Value*. Hvězdička (*) nám připomíná, že zatím nevíme, co tato proměnná dělá, ale nemůže sloužit jen ke sledování hodnoty kontraktu, protože není potřeba používat úložiště (storage), které je velmi drahé, když můžete získat zůstatek svého účtu pomocí ADDRESS BALANCE. První operační kód vloží vlastní adresu kontraktu. Druhý přečte adresu na vrcholu zásobníku a nahradí ji zůstatkem této adresy.

OffsetOperační kódZásobník
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

Budeme pokračovat ve sledování tohoto kódu v cíli skoku.

OffsetOperační kódZásobník
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 je bitová operace, takže obrátí hodnotu každého bitu v hodnotě volání.

OffsetOperační kódZásobník
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

Skočíme, pokud je Value* menší než 2^256-CALLVALUE-1 nebo se mu rovná. Vypadá to jako logika pro zabránění přetečení (overflow). A skutečně vidíme, že po několika nesmyslných operacích (například zápis do paměti bude brzy smazán) na offsetu 0x01DE se kontrakt zvrátí, pokud je detekováno přetečení, což je normální chování.

Všimněte si, že takové přetečení je extrémně nepravděpodobné, protože by vyžadovalo, aby hodnota volání plus Value* byla srovnatelná s 2^256 wei, což je asi 10^59 ETH. Celková zásoba ETH je v době psaní tohoto textu menší než dvě stě milionů (opens in a new tab).

OffsetOperační kódZásobník
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

Pokud jsme se dostali sem, získáme Value* + CALLVALUE a skočíme na offset 0x75.

OffsetOperační kódZásobník
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Pokud se dostaneme sem (což vyžaduje, aby data volání byla prázdná), přidáme k Value* hodnotu volání. To je v souladu s tím, co jsme říkali, že dělají transakce Transfer.

OffsetOperační kód
79POP
7APOP
7BSTOP

Nakonec vyčistíme zásobník (což není nutné) a signalizujeme úspěšný konec transakce.

Abychom to shrnuli, zde je vývojový diagram pro počáteční kód.

Entry point flowchart

Obslužná rutina na 0x7C

Záměrně jsem do nadpisu neuvedl, co tato obslužná rutina dělá. Cílem není naučit vás, jak funguje tento konkrétní kontrakt, ale jak provádět reverzní inženýrství kontraktů. Zjistíte, co dělá, stejným způsobem jako já – sledováním kódu.

Dostaneme se sem z několika míst:

  • Pokud jsou data volání o velikosti 1, 2 nebo 3 bajtů (z offsetu 0x63)
  • Pokud je podpis metody neznámý (z offsetů 0x42 a 0x5D)
OffsetOperační kódZásobník
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Toto je další paměťová buňka (storage), kterou jsem nenašel v žádných transakcích, takže je těžší zjistit, co znamená. Níže uvedený kód to objasní.

OffsetOperační kódZásobník
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

Tyto operační kódy oříznou hodnotu, kterou jsme přečetli ze Storage[3], na 160 bitů, což je délka adresy na Ethereu.

OffsetOperační kódZásobník
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

Tento skok je zbytečný, protože přecházíme na další operační kód. Tento kód není zdaleka tak efektivní z hlediska plynu (gas), jak by mohl být.

OffsetOperační kódZásobník
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

Úplně na začátku kódu jsme nastavili Mem[0x40] na 0x80. Pokud se později podíváme po 0x40, uvidíme, že ho neměníme – takže můžeme předpokládat, že je to 0x80.

OffsetOperační kódZásobník
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

Zkopíruje všechna data volání do paměti, počínaje od 0x80.

OffsetOperační kódZásobník
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

Nyní jsou věci mnohem jasnější. Tento kontrakt může fungovat jako proxy kontrakt (opens in a new tab), který volá adresu ve Storage[3], aby odvedla skutečnou práci. DELEGATE_CALL volá samostatný kontrakt, ale zůstává ve stejném úložišti (storage). To znamená, že delegovaný kontrakt, pro který jsme proxy, přistupuje ke stejnému úložnému prostoru. Parametry pro volání jsou:

  • Plyn (Gas): Všechen zbývající plyn
  • Volaná adresa: Storage[3]-as-address
  • Data volání: Bajty o velikosti CALLDATASIZE začínající na 0x80, kam jsme uložili původní data volání
  • Návratová data: Žádná (0x00 - 0x00) Návratová data získáme jinými způsoby (viz níže)
OffsetOperační kódZásobník
B0RETURNDATASIZERETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address

Zde zkopírujeme všechna návratová data do paměťového bufferu začínajícího na 0x80.

OffsetOperační kódZásobník
B6DUP2(((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
B7DUP1(((úspěch/selhání volání))) (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
B8ISZERO(((selhalo volání))) (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((selhalo volání))) (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
BCJUMPI(((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
BFRETURN

Takže po volání zkopírujeme návratová data do bufferu 0x80 - 0x80+RETURNDATASIZE, a pokud je volání úspěšné, provedeme RETURN přesně s tímto bufferem.

DELEGATECALL selhal

Pokud se dostaneme sem, na 0xC0, znamená to, že volaný kontrakt se zvrátil (reverted). Protože jsme pro tento kontrakt pouze proxy, chceme vrátit stejná data a také transakci zvrátit.

OffsetOperační kódZásobník
C0JUMPDEST(((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address
C3REVERT

Takže provedeme REVERT se stejným bufferem, jaký jsme dříve použili pro RETURN: 0x80 - 0x80+RETURNDATASIZE

Call to proxy flowchart

Volání ABI

Pokud je velikost dat volání čtyři bajty nebo více, může se jednat o platné volání ABI.

OffsetOperační kódZásobník
DPUSH1 0x000x00
FCALLDATALOAD(((První slovo (256 bitů) dat volání)))
10PUSH1 0xe00xE0 (((První slovo (256 bitů) dat volání)))
12SHR(((prvních 32 bitů (4 bajty) dat volání)))

Etherscan nám říká, že 1C je neznámý operační kód, protože byl přidán až poté, co Etherscan tuto funkci napsal, (opens in a new tab) a ještě ji neaktualizovali. Aktuální tabulka operačních kódů (opens in a new tab) nám ukazuje, že se jedná o bitový posun vpravo (shift right).

OffsetOperační kódZásobník
13DUP1(((prvních 32 bitů (4 bajty) dat volání))) (((prvních 32 bitů (4 bajty) dat volání)))
14PUSH4 0x3cd8045e0x3CD8045E (((prvních 32 bitů (4 bajty) dat volání))) (((prvních 32 bitů (4 bajty) dat volání)))
19GT0x3CD8045E>prvních-32-bitů-dat-volání (((prvních 32 bitů (4 bajty) dat volání)))
1APUSH2 0x00430x43 0x3CD8045E>prvních-32-bitů-dat-volání (((prvních 32 bitů (4 bajty) dat volání)))
1DJUMPI(((prvních 32 bitů (4 bajty) dat volání)))

Rozdělení testů shody podpisu metody na dvě části tímto způsobem ušetří v průměru polovinu testů. Kód, který bezprostředně následuje, a kód na adrese 0x43 se řídí stejným vzorem: DUP1 prvních 32 bitů dat volání, PUSH4 (((method signature>, spustí EQ pro kontrolu rovnosti a poté JUMPI, pokud se podpis metody shoduje. Zde jsou podpisy metod, jejich adresy a, pokud je známa, odpovídající definice metody (opens in a new tab):

MetodaPodpis metodyOffset pro skok
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Pokud není nalezena žádná shoda, kód přeskočí na obslužnou rutinu proxy na adrese 0x7C v naději, že kontrakt, pro který jsme proxy, má shodu.

ABI calls flowchart

splitter()

OffsetOperační kódZásobník
103JUMPDEST
104CALLVALUECALLVALUE
105DUP1CALLVALUE CALLVALUE
106ISZEROCALLVALUE==0 CALLVALUE
107PUSH2 0x010f0x010F CALLVALUE==0 CALLVALUE
10AJUMPICALLVALUE
10BPUSH1 0x000x00 CALLVALUE
10DDUP10x00 0x00 CALLVALUE
10EREVERT

První věc, kterou tato funkce dělá, je kontrola, zda volání neposlalo žádné ETH. Tato funkce není payable (opens in a new tab). Pokud nám někdo poslal ETH, musí to být chyba a my chceme provést REVERT, abychom se vyhnuli tomu, že toto ETH zůstane tam, odkud ho už nemohou získat zpět.

OffsetOperační kódZásobník
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] neboli kontrakt, pro který jsme proxy)))
114PUSH1 0x400x40 (((Storage[3] neboli kontrakt, pro který jsme proxy)))
116MLOAD0x80 (((Storage[3] neboli kontrakt, pro který jsme proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] neboli kontrakt, pro který jsme proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] neboli kontrakt, pro který jsme proxy)))
12DSWAP2(((Storage[3] neboli kontrakt, pro který jsme proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

A 0x80 nyní obsahuje adresu proxy

OffsetOperační kódZásobník
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Kód E4

Toto je poprvé, co vidíme tyto řádky, ale jsou sdíleny s dalšími metodami (viz níže). Hodnotu v zásobníku tedy nazveme X a budeme si pamatovat, že v splitter() je hodnota tohoto X rovna 0xA0.

OffsetOperační kódZásobník
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

Tento kód tedy přijme ukazatel do paměti v zásobníku (X) a způsobí, že kontrakt provede RETURN s bufferem, který je 0x80 - X.

V případě splitter() to vrací adresu, pro kterou jsme proxy. RETURN vrací buffer v 0x80-0x9F, což je místo, kam jsme tato data zapsali (offset 0x130 výše).

currentWindow()

Kód na offsetech 0x158-0x163 je identický s tím, co jsme viděli na 0x103-0x10E v splitter() (kromě cíle JUMPI), takže víme, že currentWindow() také není payable.

OffsetOperační kódZásobník
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

Kód DA

Tento kód je také sdílen s dalšími metodami. Hodnotu v zásobníku tedy nazveme Y a budeme si pamatovat, že v currentWindow() je hodnota tohoto Y Storage[1].

OffsetOperační kódZásobník
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Zapíše Y na 0x80-0x9F.

OffsetOperační kódZásobník
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

A zbytek je již vysvětlen výše. Skoky na 0xDA tedy zapíší vrchol zásobníku (Y) na 0x80-0x9F a vrátí tuto hodnotu. V případě currentWindow() vrací Storage[1].

merkleRoot()

Kód na offsetech 0xED-0xF8 je identický s tím, co jsme viděli na 0x103-0x10E v splitter() (až na cíl JUMPI), takže víme, že merkleRoot() také není payable.

OffsetOperační kódZásobník
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Co se stane po skoku, jsme už zjistili. Takže merkleRoot() vrací Storage[0].

0x81e580d3

Kód na offsetech 0x138-0x143 je identický s tím, co jsme viděli na 0x103-0x10E v splitter() (kromě cíle JUMPI), takže víme, že tato funkce také není payable.

OffsetOperační kódZásobník
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

Vypadá to, že tato funkce přijímá alespoň 32 bajtů (jedno slovo) dat volání.

OffsetOperační kódZásobník
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Pokud nezíská data volání, transakce je zvrácena bez jakýchkoli návratových dat.

Pojďme se podívat, co se stane, pokud funkce získá data volání, která potřebuje.

OffsetOperační kódZásobník
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) je první slovo dat volání za podpisem metody

OffsetOperační kódZásobník
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

Pokud první slovo není menší než Storage[4], funkce selže. Zvrátí se bez jakékoli návratové hodnoty:

OffsetOperační kódZásobník
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Pokud je calldataload(4) menší než Storage[4], dostaneme tento kód:

OffsetOperační kódZásobník
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 paměťová místa 0x00-0x1F nyní obsahují data 0x04 (0x00-0x1E jsou samé nuly, 0x1F je čtyři)

OffsetOperační kódZásobník
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

V úložišti je tedy vyhledávací tabulka (lookup table), která začíná na SHA3 z 0x000...0004 a má záznam pro každou legitimní hodnotu dat volání (hodnota menší než Storage[4]).

OffsetOperační kódZásobník
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

Už víme, co dělá kód na offsetu 0xDA, vrací volajícímu hodnotu z vrcholu zásobníku. Takže tato funkce vrací volajícímu hodnotu z vyhledávací tabulky.

0x1f135823

Kód na offsetech 0xC4-0xCF je identický s tím, co jsme viděli na 0x103-0x10E v splitter() (kromě cíle JUMPI), takže víme, že tato funkce také není payable.

OffsetOperační kódZásobník
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

Už víme, co dělá kód na offsetu 0xDA, vrací volajícímu hodnotu z vrcholu zásobníku. Takže tato funkce vrací Value*.

Shrnutí metod

Máte pocit, že v této chvíli už kontraktu rozumíte? Já ne. Zatím tu máme tyto metody:

MetodaVýznam
TransferPřijme hodnotu poskytnutou voláním a zvýší Value* o tuto částku
splitter()Vrátí Storage[3], adresu proxy
currentWindow()Vrátí Storage[1]
merkleRoot()Vrátí Storage[0]
0x81e580d3Vrátí hodnotu z vyhledávací tabulky za předpokladu, že je parametr menší než Storage[4]
0x1f135823Vrátí Storage[6], neboli Value*

Víme ale, že jakoukoli další funkcionalitu poskytuje kontrakt ve Storage[3]. Možná, kdybychom věděli, co je to za kontrakt, napovědělo by nám to. Naštěstí je to blockchain a vše je známo, alespoň teoreticky. Neviděli jsme žádné metody, které by nastavovaly Storage[3], takže to musel nastavit konstruktor.

Konstruktor

Když se podíváme na kontrakt (opens in a new tab), můžeme také vidět transakci, která ho vytvořila.

Click the create transaction

Pokud na tuto transakci klikneme a poté přejdeme na záložku Stav, uvidíme počáteční hodnoty parametrů. Konkrétně můžeme vidět, že Storage[3] obsahuje 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Tento kontrakt musí obsahovat chybějící funkcionalitu. Můžeme ji pochopit pomocí stejných nástrojů, které jsme použili pro kontrakt, který zkoumáme.

Proxy kontrakt

Pomocí stejných technik, jaké jsme použili u původního kontraktu výše, můžeme vidět, že se kontrakt zvrátí, pokud:

  • Je k volání připojeno jakékoli ETH (0x05-0x0F)
  • Velikost dat volání je menší než čtyři (0x10-0x19 a 0xBE-0xC2)

A že metody, které podporuje, jsou:

Můžeme ignorovat spodní čtyři metody, protože se k nim nikdy nedostaneme. Jejich podpisy jsou takové, že se o ně náš původní kontrakt postará sám (můžete kliknout na podpisy a podívat se na podrobnosti výše), takže to musí být metody, které jsou přepsány (opens in a new tab).

Jednou ze zbývajících metod je claim(<params>) a další je isClaimed(<params>), takže to vypadá na airdrop kontrakt. Místo toho, abychom procházeli zbytek operační kód po operačním kódu, můžeme zkusit dekompilátor (opens in a new tab), který pro tři funkce z tohoto kontraktu produkuje použitelné výsledky. Reverzní inženýrství těch ostatních je ponecháno jako cvičení pro čtenáře.

scaleAmountByPercentage

Toto nám pro tuto funkci dává dekompilátor:

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)

První require testuje, zda mají data volání kromě čtyř bajtů podpisu funkce alespoň 64 bajtů, což stačí pro dva parametry. Pokud ne, pak je zjevně něco špatně.

Příkaz if zřejmě kontroluje, že _param1 není nula a že _param1 * _param2 není záporné. Pravděpodobně to má zabránit případům přetečení (wrap around).

Nakonec funkce vrátí škálovanou hodnotu.

claim

Kód, který dekompilátor vytvoří, je složitý a ne celý je pro nás relevantní. Část z něj přeskočím, abych se zaměřil na řádky, o kterých se domnívám, že poskytují užitečné informace.

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'

Vidíme zde dvě důležité věci:

  • _param2, ačkoli je deklarováno jako uint256, je ve skutečnosti adresa
  • _param1 je nárokované okno, které musí být currentWindow nebo dřívější.
  ...
  if stor5[_claimWindow][addr(_claimFor)]:
      revert with 0, 'Account already claimed the given window'

Takže nyní víme, že Storage[5] je pole oken a adres, a zda si daná adresa nárokovala odměnu pro toto okno.

Víme, že unknown2eb4a7ab je ve skutečnosti funkce merkleRoot(), takže tento kód vypadá, že ověřuje Merkleův důkaz (opens in a new tab). To znamená, že _param4 je Merkleův důkaz.

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

Takhle kontrakt převádí své vlastní ETH na jinou adresu (kontrakt nebo externě vlastněný účet). Zavolá ji s hodnotou, která představuje částku k převodu. Takže to vypadá, že se jedná o 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

Spodní dva řádky nám říkají, že Storage[2] je také kontrakt, který voláme. Pokud se podíváme na transakci konstruktoru (opens in a new tab), uvidíme, že tento kontrakt je 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), kontrakt zabaleného etheru (WETH), jehož zdrojový kód byl nahrán na Etherscan (opens in a new tab).

Takže to vypadá, že se kontrakt pokouší odeslat ETH na _param2. Pokud to dokáže, skvělé. Pokud ne, pokusí se odeslat WETH (opens in a new tab). Pokud je _param2 externě vlastněný účet (EOA), pak může vždy přijímat ETH, ale kontrakty mohou přijetí ETH odmítnout. WETH je však ERC-20 a kontrakty nemohou jeho přijetí odmítnout.

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

Na konci funkce vidíme, že se generuje log. Podívejte se na vygenerované logy (opens in a new tab) a filtrujte podle tématu, které začíná na 0xdbd5.... Pokud klikneme na jednu z transakcí, která takový log vygenerovala (opens in a new tab), uvidíme, že to skutečně vypadá jako nárok – účet odeslal zprávu kontraktu, u kterého provádíme reverzní inženýrství, a na oplátku získal ETH.

A claim transaction

1e7df9d3

Tato funkce je velmi podobná claim výše. Také kontroluje Merkleův důkaz, pokouší se převést ETH na první adresu a produkuje stejný typ logu.

Hlavním rozdílem je, že první parametr, okno pro výběr, zde není. Místo toho je zde smyčka přes všechna okna, která by mohla být nárokována.

Takže to vypadá jako varianta claim, která nárokuje všechna okna.

Závěr

Nyní byste už měli vědět, jak porozumět kontraktům, jejichž zdrojový kód není k dispozici, a to buď pomocí operačních kódů, nebo (když to funguje) dekompilátoru. Jak je zřejmé z délky tohoto článku, reverzní inženýrství kontraktu není triviální, ale v systému, kde je bezpečnost zásadní, je důležitou dovedností umět ověřit, že kontrakty fungují tak, jak bylo slíbeno.

Více z mé práce najdete zde (opens in a new tab).