Vai al contenuto principale

Reverse engineering di un contratto

evm
opcodes
Avanzato
Ori Pomerantz
30 dicembre 2021
32 minuti di lettura

Introduzione

Non ci sono segreti sulla blockchain, tutto ciò che accade è coerente, verificabile e pubblicamente disponibile. Idealmente, i contratti dovrebbero avere il loro codice sorgente pubblicato e verificato su Etherscan (opens in a new tab). Tuttavia, non è sempre così (opens in a new tab). In questo articolo imparerai come fare il reverse engineering dei contratti esaminando un contratto senza codice sorgente, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Esistono compilatori inversi, ma non sempre producono risultati utilizzabili (opens in a new tab). In questo articolo imparerai come fare manualmente il reverse engineering e comprendere un contratto a partire dagli opcode (opens in a new tab), oltre a come interpretare i risultati di un decompilatore.

Per poter comprendere questo articolo dovresti già conoscere le basi della EVM ed avere almeno una certa familiarità con l'assembler della EVM. Puoi leggere di più su questi argomenti qui (opens in a new tab).

Preparare il codice eseguibile

Puoi ottenere gli opcode andando su Etherscan per il contratto, cliccando sulla scheda Contract e poi su Switch to Opcodes View. Otterrai una visualizzazione con un opcode per riga.

Visualizzazione degli opcode da Etherscan

Per poter comprendere i salti (jump), tuttavia, devi sapere dove si trova ciascun opcode nel codice. Per farlo, un modo è aprire un Foglio Google e incollare gli opcode nella colonna C. Puoi saltare i passaggi seguenti creando una copia di questo foglio di calcolo già preparato (opens in a new tab).

Il passaggio successivo è ottenere le posizioni corrette del codice in modo da poter comprendere i salti. Inseriremo la dimensione dell'opcode nella colonna B e la posizione (in esadecimale) nella colonna A. Digita questa funzione nella cella B1 e poi copiala e incollala per il resto della colonna B, fino alla fine del codice. Dopo averlo fatto, puoi nascondere la colonna B.

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

Innanzitutto questa funzione aggiunge un byte per l'opcode stesso, e poi cerca PUSH. Gli opcode push sono speciali perché devono avere byte aggiuntivi per il valore che viene inserito (pushed). Se l'opcode è un PUSH, estraiamo il numero di byte e lo aggiungiamo.

In A1 inserisci il primo offset, zero. Quindi, in A2, inserisci questa funzione e copiala e incollala di nuovo per il resto della colonna A:

1=dec2hex(hex2dec(A1)+B1)

Abbiamo bisogno che questa funzione ci dia il valore esadecimale perché i valori che vengono inseriti prima dei salti (JUMP e JUMPI) ci vengono forniti in esadecimale.

Il Punto di Ingresso (0x00)

I contratti vengono sempre eseguiti dal primo byte. Questa è la parte iniziale del codice:

OffsetOpcodeStack (dopo l'opcode)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREVuoto
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIVuoto

Questo codice fa due cose:

  1. Scrive 0x80 come valore a 32 byte nelle posizioni di memoria 0x40-0x5F (0x80 è memorizzato in 0x5F e 0x40-0x5E sono tutti zeri).
  2. Legge la dimensione dei calldata. Normalmente i dati di chiamata per un contratto di Ethereum seguono l'ABI (interfaccia binaria dell'applicazione) (opens in a new tab), che richiede come minimo quattro byte per il selettore di funzione. Se la dimensione dei dati di chiamata è inferiore a quattro, salta a 0x5E.

Diagramma di flusso per questa porzione

Il Gestore a 0x5E (per dati di chiamata non-ABI)

OffsetOpcode
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Questo frammento inizia con un JUMPDEST. I programmi della EVM (macchina virtuale di Ethereum) generano un'eccezione se si salta a un opcode che non è JUMPDEST. Poi guarda la CALLDATASIZE e, se è "vera" (cioè non zero), salta a 0x7C. Ci arriveremo più avanti.

OffsetOpcodeStack (dopo l'opcode)
64CALLVALUE forniti dalla chiamata. Chiamato msg.value in Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Quindi, quando non ci sono dati di chiamata, leggiamo il valore di Storage[6]. Non sappiamo ancora quale sia questo valore, ma possiamo cercare le transazioni che il contratto ha ricevuto senza dati di chiamata. Le transazioni che trasferiscono semplicemente ETH senza alcun dato di chiamata (e quindi senza alcun metodo) hanno in Etherscan il metodo Transfer. Infatti, la primissima transazione ricevuta dal contratto (opens in a new tab) è un trasferimento.

Se guardiamo in quella transazione e clicchiamo su Click to see More, vediamo che i dati di chiamata, chiamati dati di input, sono effettivamente vuoti (0x). Si noti anche che il valore è di 1.559 ETH, il che sarà rilevante in seguito.

I dati di chiamata sono vuoti

Successivamente, clicca sulla scheda State ed espandi il contratto di cui stiamo facendo il reverse engineering (0x2510...). Puoi vedere che Storage[6] è effettivamente cambiato durante la transazione e, se cambi Hex in Number, vedi che è diventato 1.559.000.000.000.000.000, il valore trasferito in wei (ho aggiunto le virgole per chiarezza), corrispondente al successivo valore del contratto.

Il cambiamento in Storage[6]

Se guardiamo i cambiamenti di stato causati da altre transazioni Transfer dello stesso periodo (opens in a new tab) vediamo che Storage[6] ha tracciato il valore del contratto per un po'. Per ora lo chiameremo Value*. L'asterisco (*) ci ricorda che non sappiamo ancora cosa faccia questa variabile, ma non può servire solo a tracciare il valore del contratto perché non c'è bisogno di usare l'archiviazione, che è molto costosa, quando puoi ottenere il saldo del tuo account usando ADDRESS BALANCE. Il primo opcode inserisce l'indirizzo stesso del contratto. Il secondo legge l'indirizzo in cima allo stack e lo sostituisce con il saldo di quell'indirizzo.

OffsetOpcodeStack
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

Continueremo a tracciare questo codice alla destinazione del salto.

OffsetOpcodeStack
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

Il NOT è bit a bit, quindi inverte il valore di ogni bit nel valore della chiamata.

OffsetOpcodeStack
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

Saltiamo se Value* è minore di 2^256-CALLVALUE-1 o uguale ad esso. Questa sembra una logica per prevenire l'overflow. E in effetti, vediamo che dopo alcune operazioni senza senso (la scrittura in memoria sta per essere eliminata, ad esempio) all'offset 0x01DE il contratto si annulla se viene rilevato l'overflow, il che è un comportamento normale.

Si noti che un tale overflow è estremamente improbabile, perché richiederebbe che il valore della chiamata più Value* fosse paragonabile a 2^256 wei, circa 10^59 ETH. L'offerta totale di ETH, al momento della stesura, è inferiore a duecento milioni (opens in a new tab).

OffsetOpcodeStack
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

Se siamo arrivati fin qui, ottieni Value* + CALLVALUE e salta all'offset 0x75.

OffsetOpcodeStack
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Se arriviamo qui (il che richiede che i dati di chiamata siano vuoti) aggiungiamo a Value* il valore della chiamata. Questo è coerente con ciò che diciamo facciano le transazioni Transfer.

OffsetOpcode
79POP
7APOP
7BSTOP

Infine, svuota lo stack (il che non è necessario) e segnala la fine con successo della transazione.

Per riassumere il tutto, ecco un diagramma di flusso per il codice iniziale.

Diagramma di flusso del punto di ingresso

Il Gestore a 0x7C

Di proposito non ho inserito nell'intestazione cosa fa questo gestore. Il punto non è insegnarti come funziona questo specifico contratto, ma come fare ingegneria inversa sui contratti. Imparerai cosa fa nello stesso modo in cui l'ho fatto io, seguendo il codice.

Arriviamo qui da diversi punti:

  • Se ci sono dati di chiamata di 1, 2 o 3 byte (dall'offset 0x63)
  • Se la firma del metodo è sconosciuta (dagli offset 0x42 e 0x5D)
OffsetOpcodeStack
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Questa è un'altra cella di archiviazione, una che non sono riuscito a trovare in nessuna transazione, quindi è più difficile sapere cosa significhi. Il codice sottostante lo renderà più chiaro.

OffsetOpcodeStack
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-as-address 0x9D 0x00

Questi opcode troncano il valore che leggiamo da Storage[3] a 160 bit, la lunghezza di un indirizzo Ethereum.

OffsetOpcodeStack
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

Questo salto è superfluo, poiché stiamo andando all'opcode successivo. Questo codice non è minimamente efficiente in termini di gas quanto potrebbe esserlo.

OffsetOpcodeStack
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

All'inizio del codice impostiamo Mem[0x40] a 0x80. Se cerchiamo 0x40 in seguito, vediamo che non lo modifichiamo, quindi possiamo presumere che sia 0x80.

OffsetOpcodeStack
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

Copia tutti i dati di chiamata in memoria, a partire da 0x80.

OffsetOpcodeStack
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

Ora le cose sono molto più chiare. Questo contratto può agire come un proxy (opens in a new tab), chiamando l'indirizzo in Storage[3] per fare il vero lavoro. DELEGATE_CALL chiama un contratto separato, ma rimane nello stesso spazio di archiviazione. Ciò significa che il contratto delegato, quello per cui fungiamo da proxy, accede allo stesso spazio di archiviazione. I parametri per la chiamata sono:

  • Gas: Tutto il gas rimanente
  • Indirizzo chiamato: Storage[3]-as-address
  • Dati di chiamata: I byte CALLDATASIZE a partire da 0x80, che è dove abbiamo inserito i dati di chiamata originali
  • Dati di ritorno: Nessuno (0x00 - 0x00) Otterremo i dati di ritorno con altri mezzi (vedi sotto)
OffsetOpcodeStack
B0RETURNDATASIZERETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address

Qui copiamo tutti i dati di ritorno nel buffer di memoria a partire da 0x80.

OffsetOpcodeStack
B6DUP2(((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
B7DUP1(((successo/fallimento della chiamata))) (((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
B8ISZERO(((la chiamata è fallita))) (((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((la chiamata è fallita))) (((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
BCJUMPI(((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
BFRETURN

Quindi, dopo la chiamata, copiamo i dati di ritorno nel buffer 0x80 - 0x80+RETURNDATASIZE e, se la chiamata ha esito positivo, eseguiamo un RETURN esattamente con quel buffer.

DELEGATECALL Fallita

Se arriviamo qui, a 0xC0, significa che il contratto che abbiamo chiamato è stato annullato. Poiché siamo solo un proxy per quel contratto, vogliamo restituire gli stessi dati e annullare a nostra volta.

OffsetOpcodeStack
C0JUMPDEST(((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((successo/fallimento della chiamata))) RETURNDATASIZE (((successo/fallimento della chiamata))) 0x80 Storage[3]-as-address
C3REVERT

Quindi eseguiamo un REVERT con lo stesso buffer che abbiamo usato per il RETURN in precedenza: 0x80 - 0x80+RETURNDATASIZE

Diagramma di flusso della chiamata al proxy

Chiamate ABI

Se la dimensione dei dati di chiamata è di quattro byte o più, questa potrebbe essere una chiamata ABI valida.

OffsetOpcodeStack
DPUSH1 0x000x00
FCALLDATALOAD(((Prima parola (256 bit) dei dati di chiamata)))
10PUSH1 0xe00xE0 (((Prima parola (256 bit) dei dati di chiamata)))
12SHR(((primi 32 bit (4 byte) dei dati di chiamata)))

Etherscan ci dice che 1C è un opcode sconosciuto, perché è stato aggiunto dopo che Etherscan ha scritto questa funzionalità (opens in a new tab) e non l'hanno aggiornata. Una tabella degli opcode aggiornata (opens in a new tab) ci mostra che si tratta di uno scorrimento a destra (shift right)

OffsetOpcodeStack
13DUP1(((primi 32 bit (4 byte) dei dati di chiamata))) (((primi 32 bit (4 byte) dei dati di chiamata)))
14PUSH4 0x3cd8045e0x3CD8045E (((primi 32 bit (4 byte) dei dati di chiamata))) (((primi 32 bit (4 byte) dei dati di chiamata)))
19GT0x3CD8045E>primi-32-bit-dei-dati-di-chiamata (((primi 32 bit (4 byte) dei dati di chiamata)))
1APUSH2 0x00430x43 0x3CD8045E>primi-32-bit-dei-dati-di-chiamata (((primi 32 bit (4 byte) dei dati di chiamata)))
1DJUMPI(((primi 32 bit (4 byte) dei dati di chiamata)))

Dividere i test di corrispondenza della firma del metodo in due in questo modo fa risparmiare in media metà dei test. Il codice che segue immediatamente questo e il codice in 0x43 seguono lo stesso schema: DUP1 dei primi 32 bit dei dati di chiamata, PUSH4 (((firma del metodo>, esecuzione di EQ per verificare l'uguaglianza, e poi JUMPI se la firma del metodo corrisponde. Ecco le firme dei metodi, i loro indirizzi e, se nota, la definizione del metodo corrispondente (opens in a new tab):

MetodoFirma del metodoOffset in cui saltare
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Se non viene trovata alcuna corrispondenza, il codice salta al gestore del proxy a 0x7C, nella speranza che il contratto di cui siamo un proxy abbia una corrispondenza.

Diagramma di flusso delle chiamate ABI

splitter()

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

La prima cosa che fa questa funzione è controllare che la chiamata non abbia inviato alcun ETH. Questa funzione non è payable (opens in a new tab). Se qualcuno ci ha inviato degli ETH deve trattarsi di un errore e vogliamo eseguire un REVERT per evitare di avere quegli ETH dove non possono essere recuperati.

OffsetOpcodeStack
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] alias il contratto per cui siamo un proxy)))
114PUSH1 0x400x40 (((Storage[3] alias il contratto per cui siamo un proxy)))
116MLOAD0x80 (((Storage[3] alias il contratto per cui siamo un proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] alias il contratto per cui siamo un proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] alias il contratto per cui siamo un proxy)))
12DSWAP2(((Storage[3] alias il contratto per cui siamo un proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

E 0x80 ora contiene l'indirizzo del proxy

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

Il codice E4

Questa è la prima volta che vediamo queste righe, ma sono condivise con altri metodi (vedi sotto). Quindi chiameremo il valore nello stack X, e ricorderemo semplicemente che in splitter() il valore di questa X è 0xA0.

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

Quindi questo codice riceve un puntatore di memoria nello stack (X) e fa in modo che il contratto esegua un RETURN con un buffer che è 0x80 - X.

Nel caso di splitter(), questo restituisce l'indirizzo per cui siamo un proxy. RETURN restituisce il buffer in 0x80-0x9F, che è dove abbiamo scritto questi dati (offset 0x130 sopra).

currentWindow()

Il codice negli offset 0x158-0x163 è identico a quello che abbiamo visto in 0x103-0x10E in splitter() (a parte la destinazione JUMPI), quindi sappiamo che anche currentWindow() non è payable.

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

Il codice DA

Questo codice è condiviso anche con altri metodi. Quindi chiameremo il valore nello stack Y, e ricorderemo semplicemente che in currentWindow() il valore di questa Y è Storage[1].

OffsetOpcodeStack
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Scrive Y in 0x80-0x9F.

OffsetOpcodeStack
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

E il resto è già stato spiegato sopra. Quindi i salti a 0xDA scrivono la cima dello stack (Y) in 0x80-0x9F e restituiscono quel valore. Nel caso di currentWindow(), restituisce Storage[1].

merkleRoot()

Il codice negli offset 0xED-0xF8 è identico a quello che abbiamo visto in 0x103-0x10E in splitter() (a parte la destinazione JUMPI), quindi sappiamo che anche merkleRoot() non è payable.

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

Cosa succede dopo il salto lo abbiamo già capito. Quindi merkleRoot() restituisce Storage[0].

0x81e580d3

Il codice negli offset 0x138-0x143 è identico a quello che abbiamo visto in 0x103-0x10E in splitter() (a parte la destinazione di JUMPI), quindi sappiamo che anche questa funzione non è payable.

OffsetOpcodeStack
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

Sembra che questa funzione accetti almeno 32 byte (una word) di dati di chiamata.

OffsetOpcodeStack
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Se non riceve i dati di chiamata, la transazione viene annullata senza alcun dato di ritorno.

Vediamo cosa succede se la funzione riceve effettivamente i dati di chiamata di cui ha bisogno.

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

calldataload(4) è la prima word dei dati di chiamata dopo la firma del metodo

OffsetOpcodeStack
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

Se la prima word non è minore di Storage[4], la funzione fallisce. Viene annullata senza alcun valore restituito:

OffsetOpcodeStack
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Se calldataload(4) è minore di Storage[4], otteniamo questo codice:

OffsetOpcodeStack
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

E le posizioni di memoria 0x00-0x1F ora contengono il dato 0x04 (0x00-0x1E sono tutti zeri, 0x1F è quattro)

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

Quindi c'è una tabella di ricerca nello storage, che inizia allo SHA3 di 0x000...0004 e ha una voce per ogni valore legittimo dei dati di chiamata (valore inferiore a Storage[4]).

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

Sappiamo già cosa fa il codice all'offset 0xDA, restituisce il valore in cima allo stack al chiamante. Quindi questa funzione restituisce il valore dalla tabella di ricerca al chiamante.

0x1f135823

Il codice negli offset 0xC4-0xCF è identico a quello che abbiamo visto in 0x103-0x10E in splitter() (a parte la destinazione JUMPI), quindi sappiamo che anche questa funzione non è payable.

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

Sappiamo già cosa fa il codice all'offset 0xDA, restituisce il valore in cima allo stack al chiamante. Quindi questa funzione restituisce Value*.

Riepilogo dei metodi

Senti di aver compreso il contratto a questo punto? Io no. Finora abbiamo questi metodi:

MetodoSignificato
TransferAccetta il valore fornito dalla chiamata e incrementa Value* di tale importo
splitter()Restituisce Storage[3], l'indirizzo del proxy
currentWindow()Restituisce Storage[1]
merkleRoot()Restituisce Storage[0]
0x81e580d3Restituisce il valore da una tabella di ricerca, a condizione che il parametro sia inferiore a Storage[4]
0x1f135823Restituisce Storage[6], noto anche come Value*

Ma sappiamo che qualsiasi altra funzionalità è fornita dal contratto in Storage[3]. Forse, se sapessimo cos'è quel contratto, ci darebbe un indizio. Fortunatamente, questa è la blockchain e tutto è noto, almeno in teoria. Non abbiamo visto alcun metodo che imposti Storage[3], quindi deve essere stato impostato dal costruttore.

Il Costruttore

Quando osserviamo un contratto (opens in a new tab) possiamo anche vedere la transazione che lo ha creato.

Fai clic sulla transazione di creazione

Se facciamo clic su quella transazione, e poi sulla scheda Stato, possiamo vedere i valori iniziali dei parametri. Nello specifico, possiamo vedere che Storage[3] contiene 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Quel contratto deve contenere la funzionalità mancante. Possiamo comprenderlo usando gli stessi strumenti che abbiamo usato per il contratto che stiamo indagando.

Il Contratto Proxy

Usando le stesse tecniche che abbiamo usato per il contratto originale qui sopra, possiamo vedere che il contratto si annulla se:

  • C'è qualche ETH allegato alla chiamata (0x05-0x0F)
  • La dimensione dei dati della chiamata è inferiore a quattro (0x10-0x19 e 0xBE-0xC2)

E che i metodi che supporta sono:

Possiamo ignorare gli ultimi quattro metodi perché non ci arriveremo mai. Le loro firme sono tali che il nostro contratto originale se ne occupa da solo (puoi cliccare sulle firme per vedere i dettagli sopra), quindi devono essere metodi sovrascritti (opens in a new tab).

Uno dei metodi rimanenti è claim(<params>), e un altro è isClaimed(<params>), quindi sembra un contratto di airdrop. Invece di esaminare il resto opcode per opcode, possiamo provare il decompilatore (opens in a new tab), che produce risultati utilizzabili per tre funzioni di questo contratto. Il reverse engineering delle altre è lasciato come esercizio per il lettore.

scaleAmountByPercentage

Questo è ciò che il decompilatore ci fornisce per questa funzione:

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)

Il primo require verifica che i dati della chiamata abbiano, oltre ai quattro byte della firma della funzione, almeno 64 byte, sufficienti per i due parametri. In caso contrario, c'è ovviamente qualcosa di sbagliato.

L'istruzione if sembra controllare che _param1 non sia zero e che _param1 * _param2 non sia negativo. Probabilmente serve a prevenire casi di wrap around (overflow).

Infine, la funzione restituisce un valore scalato.

claim

Il codice creato dal decompilatore è complesso e non tutto è rilevante per noi. Ne salterò una parte per concentrarmi sulle righe che ritengo forniscano informazioni utili

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'

Qui vediamo due cose importanti:

  • _param2, sebbene sia dichiarato come uint256, è in realtà un indirizzo
  • _param1 è la finestra che viene riscattata, che deve essere currentWindow o precedente.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'

Quindi ora sappiamo che Storage[5] è un array di finestre e indirizzi, e se l'indirizzo ha riscattato la ricompensa per quella finestra.

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'
Mostra tutto

Sappiamo che unknown2eb4a7ab è in realtà la funzione merkleRoot(), quindi questo codice sembra verificare una prova di Merkle (opens in a new tab). Ciò significa che _param4 è una prova di Merkle.

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

Questo è il modo in cui un contratto trasferisce i propri ETH a un altro indirizzo (contratto o account controllato esternamente). Lo chiama con un valore che è l'importo da trasferire. Quindi sembra che si tratti di un airdrop di 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

Le ultime due righe ci dicono che anche Storage[2] è un contratto che chiamiamo. Se guardiamo la transazione del costruttore (opens in a new tab) vediamo che questo contratto è 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), un contratto Wrapped Ether il cui codice sorgente è stato caricato su Etherscan (opens in a new tab).

Quindi sembra che il contratto tenti di inviare ETH a _param2. Se ci riesce, ottimo. Altrimenti, tenta di inviare WETH (opens in a new tab). Se _param2 è un account controllato esternamente (EOA), allora può sempre ricevere ETH, ma i contratti possono rifiutarsi di ricevere ETH. Tuttavia, WETH è ERC-20 e i contratti non possono rifiutarsi di accettarlo.

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

Alla fine della funzione vediamo che viene generata una voce di log. Guarda le voci di log generate (opens in a new tab) e filtra per l'argomento che inizia con 0xdbd5.... Se clicchiamo su una delle transazioni che ha generato tale voce (opens in a new tab) vediamo che in effetti sembra un riscatto: l'account ha inviato un messaggio al contratto di cui stiamo facendo il reverse engineering e in cambio ha ottenuto ETH.

Una transazione di riscatto

1e7df9d3

Questa funzione è molto simile a claim qui sopra. Controlla anch'essa una prova di Merkle, tenta di trasferire ETH al primo e produce lo stesso tipo di voce di log.

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)
Mostra tutto

La differenza principale è che il primo parametro, la finestra da prelevare, non c'è. Invece, c'è un ciclo su tutte le finestre che potrebbero essere riscattate.

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
Mostra tutto

Quindi sembra una variante di claim che riscatta tutte le finestre.

Conclusione

A questo punto dovresti sapere come comprendere i contratti il cui codice sorgente non è disponibile, utilizzando gli opcode o (quando funziona) il decompilatore. Come è evidente dalla lunghezza di questo articolo, il reverse engineering di un contratto non è banale, ma in un sistema in cui la sicurezza è essenziale è una competenza importante per poter verificare che i contratti funzionino come promesso.

Vedi qui per altri miei lavori (opens in a new tab).

Ultimo aggiornamento della pagina: 3 marzo 2026

Questo tutorial è stato utile?