Reverzní inženýrství kontraktu
Ú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.
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:
| Offset | Operační kód | Zásobník (po operačním kódu) |
|---|---|---|
| 0 | PUSH1 0x80 | 0x80 |
| 2 | PUSH1 0x40 | 0x40, 0x80 |
| 4 | MSTORE | Prázdný |
| 5 | PUSH1 0x04 | 0x04 |
| 7 | CALLDATASIZE | CALLDATASIZE 0x04 |
| 8 | LT | CALLDATASIZE<4 |
| 9 | PUSH2 0x005e | 0x5E CALLDATASIZE<4 |
| C | JUMPI | Prázdný |
Tento kód dělá dvě věci:
- 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).
- 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.
Obslužná rutina na 0x5E (pro data volání mimo ABI)
| Offset | Operační kód |
|---|---|
| 5E | JUMPDEST |
| 5F | CALLDATASIZE |
| 60 | PUSH2 0x007c |
| 63 | JUMPI |
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.
| Offset | Operační kód | Zásobník (po operačním kódu) |
|---|---|---|
| 64 | CALLVALUE | poskytnuté voláním. V Solidity se nazývá msg.value |
| 65 | PUSH1 0x06 | 6 CALLVALUE |
| 67 | PUSH1 0x00 | 0 6 CALLVALUE |
| 69 | DUP3 | CALLVALUE 0 6 CALLVALUE |
| 6A | DUP3 | 6 CALLVALUE 0 6 CALLVALUE |
| 6B | SLOAD | Storage[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.
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.
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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 6C | PUSH2 0x0075 | 0x75 Value* CALLVALUE 0 6 CALLVALUE |
| 6F | SWAP2 | CALLVALUE Value* 0x75 0 6 CALLVALUE |
| 70 | SWAP1 | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 71 | PUSH2 0x01a7 | 0x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 74 | JUMP |
Budeme pokračovat ve sledování tohoto kódu v cíli skoku.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 1A7 | JUMPDEST | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1A8 | PUSH1 0x00 | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AA | DUP3 | CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AB | NOT | 2^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í.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 1AC | DUP3 | Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AD | GT | Value*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AE | ISZERO | Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AF | PUSH2 0x01df | 0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1B2 | JUMPI |
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).
| Offset | Operační kód | Zásobník |
|---|---|---|
| 1DF | JUMPDEST | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E0 | POP | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E1 | ADD | Value*+CALLVALUE 0x75 0 6 CALLVALUE |
| 1E2 | SWAP1 | 0x75 Value*+CALLVALUE 0 6 CALLVALUE |
| 1E3 | JUMP |
Pokud jsme se dostali sem, získáme Value* + CALLVALUE a skočíme na offset 0x75.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 75 | JUMPDEST | Value*+CALLVALUE 0 6 CALLVALUE |
| 76 | SWAP1 | 0 Value*+CALLVALUE 6 CALLVALUE |
| 77 | SWAP2 | 6 Value*+CALLVALUE 0 CALLVALUE |
| 78 | SSTORE | 0 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.
| Offset | Operační kód |
|---|---|
| 79 | POP |
| 7A | POP |
| 7B | STOP |
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.
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)
| Offset | Operační kód | Zásobník |
|---|---|---|
| 7C | JUMPDEST | |
| 7D | PUSH1 0x00 | 0x00 |
| 7F | PUSH2 0x009d | 0x9D 0x00 |
| 82 | PUSH1 0x03 | 0x03 0x9D 0x00 |
| 84 | SLOAD | Storage[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í.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 85 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xff....ff Storage[3] 0x9D 0x00 |
| 9A | AND | Storage[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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 9B | SWAP1 | 0x9D Storage[3]-as-address 0x00 |
| 9C | JUMP | Storage[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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 9D | JUMPDEST | Storage[3]-as-address 0x00 |
| 9E | SWAP1 | 0x00 Storage[3]-as-address |
| 9F | POP | Storage[3]-as-address |
| A0 | PUSH1 0x40 | 0x40 Storage[3]-as-address |
| A2 | MLOAD | Mem[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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| A3 | CALLDATASIZE | CALLDATASIZE 0x80 Storage[3]-as-address |
| A4 | PUSH1 0x00 | 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A6 | DUP3 | 0x80 0x00 CALLDATASIZE 0x80 Storage[3]-as-address |
| A7 | CALLDATACOPY | 0x80 Storage[3]-as-address |
Zkopíruje všechna data volání do paměti, počínaje od 0x80.
| Offset | Operační kód | Zásobník |
|---|---|---|
| A8 | PUSH1 0x00 | 0x00 0x80 Storage[3]-as-address |
| AA | DUP1 | 0x00 0x00 0x80 Storage[3]-as-address |
| AB | CALLDATASIZE | CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AC | DUP4 | 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AD | DUP6 | Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AE | GAS | GAS Storage[3]-as-address 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-as-address |
| AF | DELEGATE_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)
| Offset | Operační kód | Zásobník |
|---|---|---|
| B0 | RETURNDATASIZE | RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| B1 | DUP1 | RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| B2 | PUSH1 0x00 | 0x00 RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| B4 | DUP5 | 0x80 0x00 RETURNDATASIZE RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| B5 | RETURNDATACOPY | RETURNDATASIZE (((ú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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| B6 | DUP2 | (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| B7 | DUP1 | (((úspěch/selhání volání))) (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| B8 | ISZERO | (((selhalo volání))) (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| B9 | PUSH2 0x00c0 | 0xC0 (((selhalo volání))) (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| BC | JUMPI | (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| BD | DUP2 | RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| BE | DUP5 | 0x80 RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| BF | RETURN |
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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| C0 | JUMPDEST | (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| C1 | DUP2 | RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| C2 | DUP5 | 0x80 RETURNDATASIZE (((úspěch/selhání volání))) RETURNDATASIZE (((úspěch/selhání volání))) 0x80 Storage[3]-as-address |
| C3 | REVERT |
Takže provedeme REVERT se stejným bufferem, jaký jsme dříve použili pro RETURN: 0x80 - 0x80+RETURNDATASIZE
Volání ABI
Pokud je velikost dat volání čtyři bajty nebo více, může se jednat o platné volání ABI.
| Offset | Operační kód | Zásobník |
|---|---|---|
| D | PUSH1 0x00 | 0x00 |
| F | CALLDATALOAD | (((První slovo (256 bitů) dat volání))) |
| 10 | PUSH1 0xe0 | 0xE0 (((První slovo (256 bitů) dat volání))) |
| 12 | SHR | (((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).
| Offset | Operační kód | Zásobník |
|---|---|---|
| 13 | DUP1 | (((prvních 32 bitů (4 bajty) dat volání))) (((prvních 32 bitů (4 bajty) dat volání))) |
| 14 | PUSH4 0x3cd8045e | 0x3CD8045E (((prvních 32 bitů (4 bajty) dat volání))) (((prvních 32 bitů (4 bajty) dat volání))) |
| 19 | GT | 0x3CD8045E>prvních-32-bitů-dat-volání (((prvních 32 bitů (4 bajty) dat volání))) |
| 1A | PUSH2 0x0043 | 0x43 0x3CD8045E>prvních-32-bitů-dat-volání (((prvních 32 bitů (4 bajty) dat volání))) |
| 1D | JUMPI | (((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):
| Metoda | Podpis metody | Offset pro skok |
|---|---|---|
| splitter() (opens in a new tab) | 0x3cd8045e | 0x0103 |
| ??? | 0x81e580d3 | 0x0138 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0158 |
| ??? | 0x1f135823 | 0x00C4 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x00ED |
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.
splitter()
| Offset | Operační kód | Zásobník |
|---|---|---|
| 103 | JUMPDEST | |
| 104 | CALLVALUE | CALLVALUE |
| 105 | DUP1 | CALLVALUE CALLVALUE |
| 106 | ISZERO | CALLVALUE==0 CALLVALUE |
| 107 | PUSH2 0x010f | 0x010F CALLVALUE==0 CALLVALUE |
| 10A | JUMPI | CALLVALUE |
| 10B | PUSH1 0x00 | 0x00 CALLVALUE |
| 10D | DUP1 | 0x00 0x00 CALLVALUE |
| 10E | REVERT |
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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 10F | JUMPDEST | |
| 110 | POP | |
| 111 | PUSH1 0x03 | 0x03 |
| 113 | SLOAD | (((Storage[3] neboli kontrakt, pro který jsme proxy))) |
| 114 | PUSH1 0x40 | 0x40 (((Storage[3] neboli kontrakt, pro který jsme proxy))) |
| 116 | MLOAD | 0x80 (((Storage[3] neboli kontrakt, pro který jsme proxy))) |
| 117 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xFF...FF 0x80 (((Storage[3] neboli kontrakt, pro který jsme proxy))) |
| 12C | SWAP1 | 0x80 0xFF...FF (((Storage[3] neboli kontrakt, pro který jsme proxy))) |
| 12D | SWAP2 | (((Storage[3] neboli kontrakt, pro který jsme proxy))) 0xFF...FF 0x80 |
| 12E | AND | ProxyAddr 0x80 |
| 12F | DUP2 | 0x80 ProxyAddr 0x80 |
| 130 | MSTORE | 0x80 |
A 0x80 nyní obsahuje adresu proxy
| Offset | Operační kód | Zásobník |
|---|---|---|
| 131 | PUSH1 0x20 | 0x20 0x80 |
| 133 | ADD | 0xA0 |
| 134 | PUSH2 0x00e4 | 0xE4 0xA0 |
| 137 | JUMP | 0xA0 |
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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| E4 | JUMPDEST | X |
| E5 | PUSH1 0x40 | 0x40 X |
| E7 | MLOAD | 0x80 X |
| E8 | DUP1 | 0x80 0x80 X |
| E9 | SWAP2 | X 0x80 0x80 |
| EA | SUB | X-0x80 0x80 |
| EB | SWAP1 | 0x80 X-0x80 |
| EC | RETURN |
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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 164 | JUMPDEST | |
| 165 | POP | |
| 166 | PUSH2 0x00da | 0xDA |
| 169 | PUSH1 0x01 | 0x01 0xDA |
| 16B | SLOAD | Storage[1] 0xDA |
| 16C | DUP2 | 0xDA Storage[1] 0xDA |
| 16D | JUMP | Storage[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].
| Offset | Operační kód | Zásobník |
|---|---|---|
| DA | JUMPDEST | Y 0xDA |
| DB | PUSH1 0x40 | 0x40 Y 0xDA |
| DD | MLOAD | 0x80 Y 0xDA |
| DE | SWAP1 | Y 0x80 0xDA |
| DF | DUP2 | 0x80 Y 0x80 0xDA |
| E0 | MSTORE | 0x80 0xDA |
Zapíše Y na 0x80-0x9F.
| Offset | Operační kód | Zásobník |
|---|---|---|
| E1 | PUSH1 0x20 | 0x20 0x80 0xDA |
| E3 | ADD | 0xA0 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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| F9 | JUMPDEST | |
| FA | POP | |
| FB | PUSH2 0x00da | 0xDA |
| FE | PUSH1 0x00 | 0x00 0xDA |
| 100 | SLOAD | Storage[0] 0xDA |
| 101 | DUP2 | 0xDA Storage[0] 0xDA |
| 102 | JUMP | Storage[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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 144 | JUMPDEST | |
| 145 | POP | |
| 146 | PUSH2 0x00da | 0xDA |
| 149 | PUSH2 0x0153 | 0x0153 0xDA |
| 14C | CALLDATASIZE | CALLDATASIZE 0x0153 0xDA |
| 14D | PUSH1 0x04 | 0x04 CALLDATASIZE 0x0153 0xDA |
| 14F | PUSH2 0x018f | 0x018F 0x04 CALLDATASIZE 0x0153 0xDA |
| 152 | JUMP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 18F | JUMPDEST | 0x04 CALLDATASIZE 0x0153 0xDA |
| 190 | PUSH1 0x00 | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 192 | PUSH1 0x20 | 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 194 | DUP3 | 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 195 | DUP5 | CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 196 | SUB | CALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 197 | SLT | CALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 198 | ISZERO | CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 199 | PUSH2 0x01a0 | 0x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19C | JUMPI | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
Vypadá to, že tato funkce přijímá alespoň 32 bajtů (jedno slovo) dat volání.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 19D | DUP1 | 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19E | DUP2 | 0x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19F | REVERT |
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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| 1A0 | JUMPDEST | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A1 | POP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A2 | CALLDATALOAD | calldataload(4) CALLDATASIZE 0x0153 0xDA |
calldataload(4) je první slovo dat volání za podpisem metody
| Offset | Operační kód | Zásobník |
|---|---|---|
| 1A3 | SWAP2 | 0x0153 CALLDATASIZE calldataload(4) 0xDA |
| 1A4 | SWAP1 | CALLDATASIZE 0x0153 calldataload(4) 0xDA |
| 1A5 | POP | 0x0153 calldataload(4) 0xDA |
| 1A6 | JUMP | calldataload(4) 0xDA |
| 153 | JUMPDEST | calldataload(4) 0xDA |
| 154 | PUSH2 0x016e | 0x016E calldataload(4) 0xDA |
| 157 | JUMP | calldataload(4) 0xDA |
| 16E | JUMPDEST | calldataload(4) 0xDA |
| 16F | PUSH1 0x04 | 0x04 calldataload(4) 0xDA |
| 171 | DUP2 | calldataload(4) 0x04 calldataload(4) 0xDA |
| 172 | DUP2 | 0x04 calldataload(4) 0x04 calldataload(4) 0xDA |
| 173 | SLOAD | Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 174 | DUP2 | calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 175 | LT | calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 176 | PUSH2 0x017e | 0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 179 | JUMPI | calldataload(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:
| Offset | Operační kód | Zásobník |
|---|---|---|
| 17A | PUSH1 0x00 | 0x00 ... |
| 17C | DUP1 | 0x00 0x00 ... |
| 17D | REVERT |
Pokud je calldataload(4) menší než Storage[4], dostaneme tento kód:
| Offset | Operační kód | Zásobník |
|---|---|---|
| 17E | JUMPDEST | calldataload(4) 0x04 calldataload(4) 0xDA |
| 17F | PUSH1 0x00 | 0x00 calldataload(4) 0x04 calldataload(4) 0xDA |
| 181 | SWAP2 | 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 182 | DUP3 | 0x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 183 | MSTORE | calldataload(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)
| Offset | Operační kód | Zásobník |
|---|---|---|
| 184 | PUSH1 0x20 | 0x20 calldataload(4) 0x00 calldataload(4) 0xDA |
| 186 | SWAP1 | calldataload(4) 0x20 0x00 calldataload(4) 0xDA |
| 187 | SWAP2 | 0x00 0x20 calldataload(4) calldataload(4) 0xDA |
| 188 | SHA3 | (((SHA3 z 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA |
| 189 | ADD | (((SHA3 z 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA |
| 18A | SLOAD | Storage[(((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]).
| Offset | Operační kód | Zásobník |
|---|---|---|
| 18B | SWAP1 | calldataload(4) Storage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18C | POP | Storage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18D | DUP2 | 0xDA Storage[(((SHA3 z 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18E | JUMP | Storage[(((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.
| Offset | Operační kód | Zásobník |
|---|---|---|
| D0 | JUMPDEST | |
| D1 | POP | |
| D2 | PUSH2 0x00da | 0xDA |
| D5 | PUSH1 0x06 | 0x06 0xDA |
| D7 | SLOAD | Value* 0xDA |
| D8 | DUP2 | 0xDA Value* 0xDA |
| D9 | JUMP | Value* 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:
| Metoda | Význam |
|---|---|
| Transfer | Př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] |
| 0x81e580d3 | Vrátí hodnotu z vyhledávací tabulky za předpokladu, že je parametr menší než Storage[4] |
| 0x1f135823 | Vrá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.
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:
| Metoda | Podpis metody | Offset pro skok |
|---|---|---|
| scaleAmountByPercentage(uint256,uint256) (opens in a new tab) | 0x8ffb5c97 | 0x0135 |
| isClaimed(uint256,address) (opens in a new tab) | 0xd2ef0795 | 0x0151 |
| claim(uint256,address,uint256,bytes32[]) (opens in a new tab) | 0x2e7ba6ef | 0x00F4 |
| incrementWindow() (opens in a new tab) | 0x338b1d31 | 0x0110 |
| ??? | 0x3f26479e | 0x0118 |
| ??? | 0x1e7df9d3 | 0x00C3 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0148 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x0107 |
| ??? | 0x81e580d3 | 0x0122 |
| ??? | 0x1f135823 | 0x00D8 |
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 jakouint256, je ve skutečnosti adresa_param1je nárokované okno, které musí býtcurrentWindownebo 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.
...
idx = 0
s = 0
while idx < _param4.length:
...
if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
mem[mem[64] + 32] = mem[(32 * idx) + 296]
...
s = sha3(mem[_62 + 32 len mem[_62]])
continue
...
s = sha3(mem[_66 + 32 len mem[_66]])
continue
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
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.
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.
def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
...
idx = 0
s = 0
while idx < _param3.length:
if idx >= mem[96]:
revert with 0, 50
_55 = mem[(32 * idx) + 128]
if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
...
s = sha3(mem[_58 + 32 len mem[_58]])
continue
mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
...
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
...
call addr(_param1) with:
value s wei
gas 30000 wei
if not return_data.size:
if not ext_call.success:
require ext_code.size(stor2)
call stor2.deposit() with:
value s wei
gas gas_remaining wei
...
log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
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.
idx = 0
s = 0
while idx < currentWindow:
...
if stor5[mem[0]]:
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s
continue
...
stor5[idx][addr(_param1)] = 1
if idx >= unknown81e580d3.length:
revert with 0, 50
mem[0] = 4
if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
revert with 0, 17
if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
revert with 0, 17
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
continue
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.



![Změna ve Storage[6]](/_next/image/?url=%2Fcontent%2Fdevelopers%2Ftutorials%2Freverse-engineering-a-contract%2Fstorage6.png&w=1920&q=75)



