Přeskočit na hlavní obsah

Reverzní inženýrství kontraktu

evm
opkódy
Další
Ori Pomerantz
30. prosince 2021
29 minuta čtení

Úvod

Na blockchainu neexistují žádná tajemství, vše, co se stane, je konzistentní, ověřitelné a veřejně dostupné. V ideálním případě by kontrakty měly mít svůj zdrojový kód zveřejněný a ověřený na Etherscanuopens in a new tab. Nicméně to tak není vždyopens in a new tab. V tomto článku se dozvíte, jak reverzně analyzovat kontrakty na příkladu kontraktu bez zdrojového kódu, 0x2510c039cc3b061d79e564b38836da87e31b342fopens in a new tab.

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

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

Příprava spustitelného kódu

Opkódy získáte tak, že na Etherscanu přejdete na kontrakt, kliknete na záložku Contract a poté na Switch to Opcodes View. Zobrazí se vám pohled s jedním opkódem na řádek.

Pohled na opkódy z Etherscanu

Abyste však porozuměli skokům, musíte vědět, kde v kódu se který opkód nachází. Jedním ze způsobů, jak to udělat, je otevřít tabulku Google a vložit opkódy do sloupce C. Následující kroky můžete přeskočit tak, že si vytvoříte kopii této již připravené tabulkyopens in a new tab.

Dalším krokem je získání správných umístění v kódu, abychom mohli pochopit skoky. Velikost opkódu vložíme do sloupce B a umístění (v hexadecimálním tvaru) do sloupce A. Zadejte tuto funkci do buňky B1 a poté ji zkopírujte a vložte do zbytku sloupce B, až na konec kódu. Poté můžete sloupec B skrýt.

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

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

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

1=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 tvaru.

Vstupní bod (0x00)

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

OffsetOpkódZásobník (po opkó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. Zapište 0x80 jako 32bajtovou hodnotu do paměťových míst 0x40-0x5F (0x80 se uloží do 0x5F a 0x40-0x5E jsou všechny nuly).
  2. Přečtěte velikost calldata. Normálně se data volání (calldata) pro kontrakt na Ethereu řídí ABI (application binary interface)opens in a new tab, které vyžaduje minimálně čtyři bajty pro selektor funkce. Pokud je velikost calldata menší než čtyři, skočí se na 0x5E.

Vývojový diagram pro tuto část

Handler na 0x5E (pro data volání bez ABI)

OffsetOpkód
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Tento úryvek začíná JUMPDEST. Programy EVM (Ethereum Virtual Machine) vyvolají výjimku, pokud skočíte na opkód, který není JUMPDEST. Poté se podívá na CALLDATASIZE, a pokud je „true“ (tedy nenulová), skočí na 0x7C. K tomu se dostaneme níže.

OffsetOpkódZásobník (po opkó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ž nejsou žádná calldata, 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 calldata. Transakce, které pouze převádějí ETH bez jakýchkoli calldata (a tedy bez metody), mají v Etherscanu metodu Transfer. Ve skutečnosti úplně první transakce, kterou kontrakt obdrželopens in a new tab, je převod.

Když se podíváme na tuto transakci a klikneme na Click to see More, uvidíme, že data volání (calldata), zde nazvaná vstupní data, jsou skutečně prázdná (0x). Všimněte si také, že hodnota je 1,559 ETH, což bude relevantní později.

Calldata jsou prázdná

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

Změna v Storage[6]

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

OffsetOpkódZásobník
6CPUSH2 0x00750x75 Hodnota* CALLVALUE 0 6 CALLVALUE
6FSWAP2CALLVALUE Hodnota* 0x75 0 6 CALLVALUE
70SWAP1Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
71PUSH2 0x01a70x01A7 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
74JUMP

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

OffsetOpkódZásobník
1A7JUMPDESTHodnota* CALLVALUE 0x75 0 6 CALLVALUE
1A8PUSH1 0x000x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
1AADUP3CALLVALUE 0x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
1ABNOT2^256-CALLVALUE-1 0x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE

NOT je bitová operace, takže obrátí hodnotu každého bitu v hodnotě volání.

OffsetOpkódZásobník
1ACDUP3Hodnota* 2^256-CALLVALUE-1 0x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
1ADGTHodnota*>2^256-CALLVALUE-1 0x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
1AEISZEROHodnota*<=2^256-CALLVALUE-1 0x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
1AFPUSH2 0x01df0x01DF Hodnota*<=2^256-CALLVALUE-1 0x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
1B2JUMPI

Skočíme, pokud je Hodnota* menší než 2^256-CALLVALUE-1 nebo se jí rovná. Vypadá to jako logika k zabránění přetečení. A skutečně, vidíme, že po několika nesmyslných operacích (například zápis do paměti, která bude brzy smazána) na offsetu 0x01DE kontrakt vrátí transakci zpět (revert), 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 Hodnota* byla srovnatelná s 2^256 wei, což je asi 10^59 ETH. Celková zásoba ETH je v době psaní tohoto článku menší než dvě stě milionůopens in a new tab.

OffsetOpkódZásobník
1DFJUMPDEST0x00 Hodnota* CALLVALUE 0x75 0 6 CALLVALUE
1E0POPHodnota* CALLVALUE 0x75 0 6 CALLVALUE
1E1ADDHodnota*+CALLVALUE 0x75 0 6 CALLVALUE
1E2SWAP10x75 Hodnota*+CALLVALUE 0 6 CALLVALUE
1E3JUMP

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

OffsetOpkódZásobník
75JUMPDESTHodnota*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Hodnota*+CALLVALUE 6 CALLVALUE
77SWAP26 Hodnota*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Pokud se dostaneme sem (což vyžaduje, aby data volání byla prázdná), přičteme k Hodnota* hodnotu volání. To je v souladu s tím, co dělají transakce Transfer.

OffsetOpkód
79POP
7APOP
7BSTOP

Nakonec vyčistěte zásobník (což není nutné) a signalizujte úspěšné ukončení transakce.

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

Vývojový diagram vstupního bodu

Handler na adrese 0x7C

Záměrně jsem do nadpisu neuvedl, co tento handler 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ů. Dozvíte se, co dělá, stejně jako já: sledováním kódu.

Dostaneme se sem z několika míst:

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

Toto je další buňka úložiště, kterou jsem v žádné transakci nenašel, takže je těžší zjistit, co znamená. Níže uvedený kód to objasní.

OffsetOpkódZásobník
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-jako-adresa 0x9D 0x00

Tyto opkódy zkrátí hodnotu, kterou čteme z Storage[3], na 160 bitů, což je délka ethereové adresy.

OffsetOpkódZásobník
9BSWAP10x9D Storage[3]-jako-adresa 0x00
9CJUMPStorage[3]-jako-adresa 0x00

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

OffsetOpkódZásobník
9DJUMPDESTStorage[3]-jako-adresa 0x00
9ESWAP10x00 Storage[3]-jako-adresa
9FPOPStorage[3]-jako-adresa
A0PUSH1 0x400x40 Storage[3]-jako-adresa
A2MLOADMem[0x40] Storage[3]-jako-adresa

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

OffsetOpkódZásobník
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-jako-adresa
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-jako-adresa
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-jako-adresa
A7CALLDATACOPY0x80 Storage[3]-jako-adresa

Zkopírujte všechna calldata do paměti, počínaje adresou 0x80.

OffsetOpkódZásobník
A8PUSH1 0x000x00 0x80 Storage[3]-jako-adresa
AADUP10x00 0x00 0x80 Storage[3]-jako-adresa
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adresa
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adresa
ADDUP6Storage[3]-jako-adresa 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adresa
AEGASGAS Storage[3]-jako-adresa 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-jako-adresa
AFDELEGATE_CALL

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

  • Gas: Veškerý zbývající gas
  • Volaná adresa: Storage[3]-jako-adresa
  • Data volání: CALLDATASIZE bajtů začínajících na adrese 0x80, což je místo, kam jsme vložili původní data volání
  • Návratová data: Žádná (0x00 - 0x00) Návratová data získáme jiným způsobem (viz níže)
OffsetOpkódZásobník
B0RETURNDATASIZERETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-jako-adresa
B1DUP1RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-jako-adresa
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-jako-adresa
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-jako-adresa
B5RETURNDATACOPYRETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-jako-adresa

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

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

Takže po volání zkopírujeme návratová data do vyrovnávací paměti 0x80 - 0x80+RETURNDATASIZE a pokud je volání úspěšné, pak RETURN s přesně touto vyrovnávací pamětí.

DELEGATECALL selhal

Pokud se dostaneme sem, na 0xC0, znamená to, že volaný kontrakt vrátil transakci zpět (revert). Jelikož jsme pouze proxy pro tento kontrakt, chceme vrátit stejná data a také provést revert.

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

Takže provedeme REVERT se stejnou vyrovnávací pamětí, kterou jsme použili pro RETURN dříve: 0x80 - 0x80+RETURNDATASIZE

Vývojový diagram volání proxy

Volání ABI

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

OffsetOpkódZásobník
DPUSH1 0x000x00
FCALLDATALOAD(((První slovo (256 bitů) z calldata)))
10PUSH1 0xe00xE0 (((První slovo (256 bitů) z calldata)))
12SHR(((prvních 32 bitů (4 bajty) z calldata)))

Etherscan nám říká, že 1C je neznámý opkód, protože byl přidán poté, co Etherscan napsal tuto funkciopens in a new tab a ještě ji neaktualizovali. Aktuální tabulka opkódůopens in a new tab nám ukazuje, že se jedná o posun doprava

OffsetOpkódZásobník
13DUP1(((prvních 32 bitů (4 bajty) z calldata))) (((prvních 32 bitů (4 bajty) z calldata)))
14PUSH4 0x3cd8045e0x3CD8045E (((prvních 32 bitů (4 bajty) z calldata))) (((prvních 32 bitů (4 bajty) z calldata)))
19GT0x3CD8045E>prvních-32-bitů-calldata (((prvních 32 bitů (4 bajty) z calldata)))
1APUSH2 0x00430x43 0x3CD8045E>prvních-32-bitů-calldata (((prvních 32 bitů (4 bajty) z calldata)))
1DJUMPI(((prvních 32 bitů (4 bajty) z calldata)))

Rozdělením testů shody podpisu metody na dvě části se v průměru ušetří polovina testů. Kód, který bezprostředně následuje, a kód na 0x43 se řídí stejným vzorem: DUP1 prvních 32 bitů calldata, PUSH4 (((podpis metody>, spustit 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 metodyopens in a new tab:

MetodaPodpis metodyOffset pro skok
splitter()opens in a new tab0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow()opens in a new tab0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot()opens in a new tab0x2eb4a7ab0x00ED

Pokud se nenajde žádná shoda, kód skočí na proxy handler na adrese 0x7C v naději, že kontrakt, pro který jsme proxy, shodu mít bude.

Vývojový diagram volání ABI

splitter()

OffsetOpkó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í payableopens in a new tab. Pokud nám někdo poslal ETH, musí to být omyl a chceme provést REVERT, abychom se vyhnuli tomu, že by se tyto ETH dostaly tam, odkud je nemohou dostat zpět.

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

A 0x80 nyní obsahuje adresu proxy

OffsetOpkó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 0xA0.

OffsetOpkó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 obdrží ukazatel na paměť v zásobníku (X) a způsobí, že kontrakt RETURN s vyrovnávací pamětí, která je 0x80 - X.

V případě splitter() vrátí adresu, pro kterou jsme proxy. RETURN vrátí vyrovnávací paměť v 0x80-0x9F, což je místo, kam jsme zapsali tato data (offset 0x130 výše).

currentWindow()

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

OffsetOpkó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].

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

Zapište Y do 0x80-0x9F.

OffsetOpkódZásobník
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

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

merkleRoot()

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

OffsetOpkó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 již zjistili. Takže merkleRoot() vrátí Storage[0].

0x81e580d3

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

OffsetOpkó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 bere alespoň 32 bajtů (jedno slovo) calldata.

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

Pokud neobdrží calldata, transakce je vrácena zpět bez jakýchkoli návratových dat.

Podívejme se, co se stane, když funkce dostane potřebná calldata.

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

calldataload(4) je první slovo calldata po podpisu metody

OffsetOpkó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. Je vrácena zpět bez jakékoli návratové hodnoty:

OffsetOpkódZásobník
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

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

OffsetOpkó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 všechny nuly, 0x1F je čtyři)

OffsetOpkó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

Takže v úložišti je vyhledávací tabulka, která začíná na SHA3 0x000...0004 a má záznam pro každou legitimní hodnotu calldata (hodnota pod Storage[4]).

OffsetOpkó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 na vrcholu zásobníku. Tato funkce tedy vrací volajícímu hodnotu z vyhledávací tabulky.

0x1f135823

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

OffsetOpkódZásobník
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADHodnota* 0xDA
D8DUP20xDA Hodnota* 0xDA
D9JUMPHodnota* 0xDA

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

Shrnutí metod

Máte pocit, že v tuto chvíli kontraktu rozumíte? Já tedy ne. Zatím máme tyto metody:

MetodaVýznam
PřevodPřijměte hodnotu poskytnutou voláním a zvyšte Hodnota* 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 parametr je menší než Storage[4]
0x1f135823Vrátí Storage[6], a.k.a. Hodnota*

Víme ale, že jakoukoli další funkcionalitu poskytuje kontrakt v Storage[3]. Možná, kdybychom věděli, co je to za kontrakt, dalo by nám to vodítko. 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 muselo být nastaveno konstruktorem.

Konstruktor

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

Klikněte na transakci vytvoření

Pokud klikneme na tuto transakci a poté na kartu State, můžeme vidět počáteční hodnoty parametrů. Konkrétně vidíme, že Storage[3] obsahuje 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761opens in a new tab. Tento kontrakt musí obsahovat chybějící funkcionalitu. Můžeme mu porozumět pomocí stejných nástrojů, které jsme použili pro kontrakt, který zkoumáme.

Kontrakt proxy

Použitím stejných technik, jaké jsme použili pro původní kontrakt výše, můžeme vidět, že kontrakt vrátí transakci zpět, pokud:

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

A metody, které podporuje, jsou:

Spodní čtyři metody můžeme ignorovat, protože se k nim nikdy nedostaneme. Jejich signatury jsou takové, že náš původní kontrakt se o ně postará sám (můžete kliknout na signatury a zobrazit podrobnosti výše), takže musí jít o přepsané metodyopens 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 opkód po opkódu, můžeme vyzkoušet dekompilátoropens in a new tab, který pro tři funkce z tohoto kontraktu vytváří použitelné výsledky. Reverzní inženýrství ostatních je ponecháno jako cvičení pro čtenáře.

scaleAmountByPercentage

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

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)

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

Příkaz if se zdá, že kontroluje, zda _param1 není nula a zda _param1 * _param2 není záporné. Pravděpodobně se tak předchází případům přetečení.

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

claim

Kód, který dekompilátor vytváří, je složitý a ne všechen je pro nás relevantní. Chystám se přeskočit některé jeho části, abych se zaměřil na řádky, které podle mě poskytují užitečné informace

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, 'cannot claim for a future window'

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

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

Takže teď víme, že Storage[5] je pole oken a adres a zda adresa nárokovala odměnu za dané okno.

1 ...
2 idx = 0
3 s = 0
4 while idx < _param4.length:
5 ...
6 if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
7 mem[mem[64] + 32] = mem[(32 * idx) + 296]
8 ...
9 s = sha3(mem[_62 + 32 len mem[_62]])
10 continue
11 ...
12 s = sha3(mem[_66 + 32 len mem[_66]])
13 continue
14 if unknown2eb4a7ab != s:
15 revert with 0, 'Invalid proof'
Zobrazit vše

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

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

Takto kontrakt převádí své vlastní ETH na jinou adresu (kontrakt nebo externě vlastněný účet). Volá ji s hodnotou, která je částkou, jež má být převedena. Vypadá to tedy, že se jedná o 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

Spodní dva řádky nám říkají, že Storage[2] je také kontrakt, který voláme. Pokud se podíváme na transakci konstruktoruopens in a new tab, vidíme, že tento kontrakt je 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2opens in a new tab, kontrakt Wrapped Ether jehož zdrojový kód byl nahrán na Etherscanopens in a new tab.

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

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

Na konci funkce vidíme generovaný záznam protokolu. Podívejte se na vygenerované záznamy protokoluopens in a new tab a filtrujte podle tématu, které začíná 0xdbd5.... Pokud klikneme na jednu z transakcí, která takový záznam vygenerovalaopens in a new tab, uvidíme, že to skutečně vypadá jako nárok – účet odeslal zprávu kontraktu, který reverzně inženýrujeme, a na oplátku dostal ETH.

Transakce nároku

1e7df9d3

Tato funkce je velmi podobná claim výše. Také kontroluje Merkleho důkaz, pokouší se převést ETH na první a vytváří stejný typ záznamu do protokolu.

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, 'Invalid proof'
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)
Zobrazit vše

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

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
Zobrazit vše

Vypadá to tedy na variantu claim, která nárokuje všechna okna.

Závěr

Nyní byste měli vědět, jak porozumět kontraktům, jejichž zdrojový kód není k dispozici, a to buď pomocí opkódů, nebo (pokud to funguje) pomocí 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í, jak slibují.

Více z mé práce najdete zdeopens in a new tab.

Stránka naposledy aktualizována: 14. února 2026

Byl tento tutoriál užitečný?