Passer au contenu principal

Ingénierie inverse d'un contrat

evm
opcodes
Avancé
Ori Pomerantz
30 décembre 2021
34 minutes de lecture

Introduction

Il n'y a pas de secrets sur la blockchain, tout ce qui se passe est cohérent, vérifiable et accessible au public. Idéalement, les contrats devraient avoir leur code source publié et vérifié sur Etherscan (opens in a new tab). Cependant, ce n'est pas toujours le cas (opens in a new tab). Dans cet article, vous apprendrez à faire de l'ingénierie inverse sur des contrats en examinant un contrat sans code source, 0x2510c039cc3b061d79e564b38836da87e31b342f (opens in a new tab).

Il existe des compilateurs inverses, mais ils ne produisent pas toujours des résultats utilisables (opens in a new tab). Dans cet article, vous apprendrez comment faire manuellement de l'ingénierie inverse et comprendre un contrat à partir des opcodes (opens in a new tab), ainsi que comment interpréter les résultats d'un décompilateur.

Pour pouvoir comprendre cet article, vous devez déjà connaître les bases de l'EVM et être au moins quelque peu familier avec l'assembleur EVM. Vous pouvez vous renseigner sur ces sujets ici (opens in a new tab).

Préparer le code exécutable

Vous pouvez obtenir les opcodes en allant sur Etherscan pour le contrat, en cliquant sur l'onglet Contract puis sur Switch to Opcodes View. Vous obtenez une vue avec un opcode par ligne.

Vue des opcodes depuis Etherscan

Toutefois, pour comprendre les sauts, vous devez savoir où se trouve chaque opcode dans le code. Pour ce faire, une solution consiste à ouvrir une feuille de calcul Google et à coller les opcodes dans la colonne C. Vous pouvez sauter les étapes suivantes en faisant une copie de cette feuille de calcul déjà préparée (opens in a new tab).

L'étape suivante consiste à obtenir les emplacements de code corrects afin de pouvoir comprendre les sauts. Nous allons mettre la taille de l'opcode dans la colonne B, et l'emplacement (en hexadécimal) dans la colonne A. Tapez cette fonction dans la cellule B1, puis copiez-la et collez-la pour le reste de la colonne B, jusqu'à la fin du code. Après cela, vous pouvez masquer la colonne B.

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

D'abord, cette fonction ajoute un octet pour l'opcode lui-même, puis recherche PUSH. Les opcodes Push sont spéciaux, car ils nécessitent des octets supplémentaires pour la valeur poussée. Si l'opcode est un PUSH, nous extrayons le nombre d'octets et nous l'ajoutons.

Dans A1 mettez le premier décalage, zéro. Ensuite, dans A2, mettez cette fonction et copiez-collez la de nouveau pour le reste de la colonne A :

1=dec2hex(hex2dec(A1)+B1)

Nous avons besoin de cette fonction pour nous donner la valeur hexadécimale, car les valeurs qui sont poussées avant les sauts (JUMP et JUMPI) nous sont données en hexadécimal.

Le point d'entrée (0x00)

Les contrats sont toujours exécutés à partir du premier octet. Ceci est la partie initiale du code :

DécalageOpcodePile (après l'opcode)
0PUSH1 0x800x80
2PUSH1 0x400x40, 0x80
4MSTOREVide
5PUSH1 0x040x04
7CALLDATASIZECALLDATASIZE 0x04
8LTCALLDATASIZE<4
9PUSH2 0x005e0x5E CALLDATASIZE<4
CJUMPIVide

Ce code fait deux choses :

  1. Écrit 0x80 comme une valeur de 32 octets aux emplacements mémoire 0x40-0x5F (0x80 est stocké dans 0x5F, et 0x40-0x5E sont tous des zéros).
  2. Lit la taille des données d'appel. Normalement, les données d'appel pour un contrat Ethereum suivent l'ABI (interface binaire d'application) (opens in a new tab), qui nécessite au minimum quatre octets pour le sélecteur de fonction. Si la taille des données d'appel est inférieure à quatre, sautez à 0x5E.

Organigramme pour cette partie

Le gestionnaire à 0x5E (pour les données d'appel non-ABI)

DécalageOpcode
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Cet extrait de code commence par un JUMPDEST. Les programmes EVM (machine virtuelle Ethereum) lèvent une exception si vous sautez vers un opcode qui n'est pas JUMPDEST. Ensuite, il examine le CALLDATASIZE, et si c'est « vrai » (c'est-à-dire non nul), il saute à 0x7C. Nous y reviendrons ci-dessous.

DécalageOpcodePile (après l'opcode)
64CALLVALUE fourni par l'appel. Appelé msg.value dans Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStockage[6] CALLVALUE 0 6 CALLVALUE

Ainsi, lorsqu'il n'y a pas de données d'appel, nous lisons la valeur de Stockage[6]. Nous ne savons pas encore quelle est cette valeur, mais nous pouvons rechercher les transactions que le contrat a reçues sans données d'appel. Les transactions qui ne font que transférer des ETH sans données d'appel (et donc sans méthode) ont dans Etherscan la méthode Transfer. En fait, la toute première transaction que le contrat a reçue (opens in a new tab) est un transfert.

Si nous examinons cette transaction et cliquons sur Click to see More, nous voyons que les données d'appel, appelées données d'entrée, sont en effet vides (0x). Notez également que la valeur est de 1,559 ETH, ce qui sera pertinent plus tard.

Les données d'appel sont vides

Ensuite, cliquez sur l'onglet State et développez le contrat sur lequel nous effectuons de l'ingénierie inverse (0x2510...). Vous pouvez voir que Stockage[6] a bien changé pendant la transaction, et si vous passez de Hex à Number, vous verrez que la valeur est devenue 1 559 000 000 000 000 000, la valeur transférée en wei (j'ai ajouté les virgules pour plus de clarté), correspondant à la valeur du contrat suivante.

Le changement dans Stockage[6]

Si nous examinons les changements d'état causés par d'autres transactions Transfer de la même période (opens in a new tab) nous voyons que Stockage[6] a suivi la valeur du contrat pendant un certain temps. Pour l'instant, nous l'appellerons Value*. L'astérisque (*) nous rappelle que nous ne savons pas encore ce que fait cette variable, mais elle ne peut pas servir uniquement à suivre la valeur du contrat, car il n'est pas nécessaire d'utiliser le stockage, qui est très coûteux, alors que vous pouvez obtenir le solde de vos comptes en utilisant ADDRESS BALANCE. Le premier opcode pousse la propre adresse du contrat. Le second lit l'adresse en haut de la pile et la remplace par le solde de cette adresse.

DécalageOpcodeBase
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

Nous continuerons à tracer ce code à la destination du saut.

DécalageOpcodeBase
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

Le NOT est un opérateur au niveau du bit, donc il inverse la valeur de chaque bit dans la valeur d'appel.

DécalageOpcodeBase
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

On saute si Value* est plus petit ou égal à 2^256-CALLVALUE-1. Cela ressemble à une logique pour éviter les dépassements. Et en effet, nous voyons qu'après quelques opérations absurdes (par exemple, écrire dans la mémoire qui est sur le point d'être effacée), au décalage 0x01DE, le contrat est annulé si le dépassement est détecté, ce qui est un comportement normal.

Notez qu'un tel dépassement est extrêmement improbable, car il faudrait que la valeur d'appel plus Value* soit comparable à 2^256 wei, soit environ 10^59 ETH. L'offre totale d'ETH, au moment de la rédaction de cet article, est inférieure à deux cents millions (opens in a new tab).

DécalageOpcodeBase
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

Si nous sommes arrivés ici, obtenez Value* + CALLVALUE et sautez au décalage 0x75.

DécalageOpcodeBase
75JUMPDESTValue*+CALLVALUE 0 6 CALLVALUE
76SWAP10 Value*+CALLVALUE 6 CALLVALUE
77SWAP26 Value*+CALLVALUE 0 CALLVALUE
78SSTORE0 CALLVALUE

Si nous arrivons ici (ce qui nécessite que les données d'appel soient vides), nous ajoutons à Value* la valeur d'appel. Ceci est cohérent avec ce que nous disons que les transactions de Transfer font.

DécalageOpcode
79POP
7APOP
7BSTOP

Enfin, videz la pile (ce qui n'est pas nécessaire) et signalez la fin réussie de la transaction.

Pour résumer, voici un organigramme du code initial.

Organigramme du point d'entrée

Le gestionnaire à 0x7C

Je n'ai volontairement pas indiqué dans le titre ce que fait ce gestionnaire. L'objectif n'est pas de vous apprendre le fonctionnement de ce contrat spécifique, mais la manière de faire de l'ingénierie inverse sur des contrats. Vous apprendrez ce qu'il fait de la même manière que moi, en suivant le code.

Nous arrivons ici depuis plusieurs endroits :

  • S'il y a des données d'appel de 1, 2 ou 3 octets (depuis le décalage 0x63)
  • Si la signature de la méthode est inconnue (depuis les décalages 0x42 et 0x5D)
DécalageOpcodeBase
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStockage[3] 0x9D 0x00

C'est une autre cellule de stockage, que je n'ai pu trouver dans aucune transaction, il est donc plus difficile de savoir ce qu'elle signifie. Le code ci-dessous clarifiera cela.

DécalageOpcodeBase
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Stockage[3] 0x9D 0x00
9AANDStockage[3](en tant qu'adresse) 0x9D 0x00

Ces opcodes tronquent la valeur que nous lisons de Stockage[3] à 160 bits, la longueur d'une adresse Ethereum.

DécalageOpcodeBase
9BSWAP10x9D Stockage[3](en tant qu'adresse) 0x00
9CJUMPStockage[3](en tant qu'adresse) 0x00

Ce saut est superflu, puisque nous allons à l'opcode suivant. Ce code n'est pas aussi économe en gaz qu'il pourrait l'être.

DécalageOpcodeBase
9DJUMPDESTStockage[3](en tant qu'adresse) 0x00
9ESWAP10x00 Stockage[3](en tant qu'adresse)
9FPOPStockage[3](en tant qu'adresse)
A0PUSH1 0x400x40 Stockage[3](en tant qu'adresse)
A2MLOADMem[0x40] Stockage[3](en tant qu'adresse)

Au tout début du code, nous avons défini Mem[0x40] sur 0x80. Si nous cherchons 0x40 plus tard, nous voyons que nous ne le changeons pas ; nous pouvons donc supposer qu'il s'agit de 0x80.

DécalageOpcodeBase
A3CALLDATASIZECALLDATASIZE 0x80 Stockage[3](en tant qu'adresse)
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Stockage[3](en tant qu'adresse)
A6DUP30x80 0x00 CALLDATASIZE 0x80 Stockage[3](en tant qu'adresse)
A7CALLDATACOPY0x80 Stockage[3](en tant qu'adresse)

Copiez toutes les données d'appel en mémoire, à partir de 0x80.

DécalageOpcodeBase
A8PUSH1 0x000x00 0x80 Stockage[3](en tant qu'adresse)
AADUP10x00 0x00 0x80 Stockage[3](en tant qu'adresse)
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Stockage[3](en tant qu'adresse)
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Stockage[3](en tant qu'adresse)
ADDUP6Stockage[3](en tant qu'adresse) 0x80 CALLDATASIZE 0x00 0x00 0x80 Stockage[3](en tant qu'adresse)
AEGASGAS Stockage[3](en tant qu'adresse) 0x80 CALLDATASIZE 0x00 0x00 0x80 Stockage[3](en tant qu'adresse)
AFDELEGATE_CALL

Les choses sont maintenant beaucoup plus claires. Ce contrat peut agir comme un proxy (opens in a new tab), en appelant l'adresse dans Stockage[3] pour faire le vrai travail. DELEGATE_CALL appelle un contrat séparé, mais reste dans le même stockage. Cela signifie que le contrat délégué, celui pour lequel nous sommes un proxy, accède au même espace de stockage. Les paramètres de l'appel sont :

  • Gaz : Tout le gaz restant
  • Adresse appelée : Stockage[3](en tant qu'adresse)
  • Données d'appel : Les octets CALLDATASIZE commençant à 0x80, là où nous avons mis les données d'appel d'origine
  • Données de retour : Aucune (0x00 - 0x00). Nous obtiendrons les données de retour par d'autres moyens (voir ci-dessous).
DécalageOpcodeBase
B0RETURNDATASIZERETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
B1DUP1RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
B5RETURNDATACOPYRETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)

Ici, nous copions toutes les données de retour dans le tampon mémoire à partir de 0x80.

DécalageOpcodeBase
B6DUP2(((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
B7DUP1(((succès/échec de l'appel))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
B8ISZERO(((l'appel a-t-il échoué))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
B9PUSH2 0x00c00xC0 (((l'appel a-t-il échoué))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
BCJUMPI(((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
BDDUP2RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
BEDUP50x80 RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
BFRETOUR

Ainsi, après l'appel, nous copions les données de retour dans le tampon 0x80 - 0x80+RETURNDATASIZE, et si l'appel réussit, nous exécutons RETURN avec exactement ce tampon.

Échec de DELEGATECALL

Si nous arrivons ici, à 0xC0, cela signifie que le contrat que nous avons appelé a été annulé. Comme nous ne sommes qu'un proxy pour ce contrat, nous voulons retourner les mêmes données et également annuler.

DécalageOpcodeBase
C0JUMPDEST(((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
C1DUP2RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
C2DUP50x80 RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Stockage[3](en tant qu'adresse)
C3REVERT

Nous exécutons donc REVERT avec le même tampon que nous avons utilisé pour RETURN précédemment : 0x80 - 0x80+RETURNDATASIZE

Organigramme de l'appel au proxy

Appels ABI

Si la taille des données d'appel est de quatre octets ou plus, il peut s'agir d'un appel ABI valide.

DécalageOpcodeBase
DPUSH1 0x000x00
FCALLDATALOAD(((Premier mot (256 bits) des données d'appel)))
10PUSH1 0xe00xE0 (((Premier mot (256 bits) des données d'appel)))
12SHR(((premiers 32 bits (4 octets) des données d'appel)))

Etherscan nous dit que 1C est un opcode inconnu, car il a été ajouté après qu'Etherscan ait écrit cette fonctionnalité (opens in a new tab) et ils ne l'ont pas mis à jour. Une table d'opcodes à jour (opens in a new tab) nous montre qu'il s'agit d'un décalage à droite

DécalageOpcodeBase
13DUP1(((premiers 32 bits (4 octets) des données d'appel))) (((premiers 32 bits (4 octets) des données d'appel)))
14PUSH4 0x3cd8045e0x3CD8045E (((premiers 32 bits (4 octets) des données d'appel))) (((premiers 32 bits (4 octets) des données d'appel)))
19GT0x3CD8045E > premiers 32 bits des données d'appel (((premiers 32 bits (4 octets) des données d'appel)))
1APUSH2 0x00430x43 0x3CD8045E > premiers 32 bits des données d'appel (((premiers 32 bits (4 octets) des données d'appel)))
1DJUMPI(((premiers 32 bits (4 octets) des données d'appel)))

Diviser ainsi les tests de correspondance de signature de méthode en deux permet de réduire de moitié le nombre moyen de tests. Le code qui suit immédiatement et le code en 0x43 suivent le même modèle : DUP1 les 32 premiers bits des données d'appel, PUSH4 (((signature de méthode>, exécutez EQ pour vérifier l'égalité, puis JUMPI si la signature de méthode correspond. Voici les signatures de méthode, leurs adresses et, si elle est connue, la définition de méthode correspondante (opens in a new tab) :

MéthodeSignature de la méthodeDécalage de destination du saut
splitter() (opens in a new tab)0x3cd8045e0x0103
???0x81e580d30x0138
currentWindow() (opens in a new tab)0xba0bafb40x0158
???0x1f1358230x00C4
merkleRoot() (opens in a new tab)0x2eb4a7ab0x00ED

Si aucune correspondance n'est trouvée, le code saute vers le gestionnaire de proxy à 0x7C, dans l'espoir que le contrat pour lequel nous sommes un proxy ait une correspondance.

Organigramme des appels ABI

splitter()

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

La première chose que fait cette fonction est de vérifier que l'appel n'a envoyé aucun ETH. Cette fonction n'est pas payable (opens in a new tab). Si quelqu'un nous a envoyé des ETH, cela doit être une erreur et nous voulons exécuter REVERT pour éviter que ces ETH ne soient bloqués là où ils ne peuvent pas être récupérés.

DécalageOpcodeBase
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Stockage[3] alias le contrat pour lequel nous sommes un proxy)))
114PUSH1 0x400x40 (((Stockage[3] alias le contrat pour lequel nous sommes un proxy)))
116MLOAD0x80 (((Stockage[3] alias le contrat pour lequel nous sommes un proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Stockage[3] alias le contrat pour lequel nous sommes un proxy)))
12CSWAP10x80 0xFF...FF (((Stockage[3] alias le contrat pour lequel nous sommes un proxy)))
12DSWAP2(((Stockage[3] alias le contrat pour lequel nous sommes un proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Et 0x80 contient maintenant l'adresse du proxy

DécalageOpcodeBase
131PUSH1 0x200x20 0x80
133ADD0xA0
134PUSH2 0x00e40xE4 0xA0
137JUMP0xA0

Le code E4

C'est la première fois que nous voyons ces lignes, mais elles sont partagées avec d'autres méthodes (voir ci-dessous). Nous appellerons donc la valeur dans la pile X, et nous nous souviendrons simplement que dans splitter(), la valeur de ce X est 0xA0.

DécalageOpcodeBase
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETOUR

Ce code reçoit donc un pointeur de mémoire dans la pile (X) et fait en sorte que le contrat exécute RETURN avec un tampon qui est 0x80 - X.

Dans le cas de splitter(), ceci retourne l'adresse pour laquelle nous sommes un proxy. RETURN renvoie le tampon dans 0x80-0x9F, où nous avons écrit ces données (décalage 0x130 ci-dessus).

currentWindow()

Le code aux décalages 0x158-0x163 est identique à ce que nous avons vu en 0x103-0x10E dans splitter() (autre que la destination JUMPI), donc nous savons que currentWindow() n'est pas non plus payable.

DécalageOpcodeBase
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStockage[1] 0xDA
16CDUP20xDA Stockage[1] 0xDA
16DJUMPStockage[1] 0xDA

Le code DA

Ce code est aussi partagé avec d'autres méthodes. Nous allons donc appeler la valeur dans la pile Y, et nous souvenir que dans currentWindow() la valeur de ce Y est Stockage[1].

DécalageOpcodeBase
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Écrire Y à 0x80-0x9F.

DécalageOpcodeBase
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Et le reste est déjà expliqué ci-dessus. Les sauts vers 0xDA écrivent donc la valeur supérieure de la pile (Y) à 0x80-0x9F, et renvoient cette valeur. Dans le cas de currentWindow(), il retourne Stockage[1].

merkleRoot()

Le code aux décalages 0xED-0xF8 est identique à ce que nous avons vu en 0x103-0x10E dans splitter() (autre que la destination JUMPI), donc nous savons que merkleRoot() n'est pas non plus payable.

DécalageOpcodeBase
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStockage[0] 0xDA
101DUP20xDA Stockage[0] 0xDA
102JUMPStockage[0] 0xDA

Nous avons déjà compris ce qu'il se passe après le saut (voir plus haut). Donc merkleRoot() retourne Stockage[0].

0x81e580d3

Le code aux décalages 0x138-0x143 est identique à ce que nous avons vu en 0x103-0x10E dans splitter() (autre que la destination JUMPI), donc nous savons que cette fonction n'est pas non plus payable.

DécalageOpcodeBase
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

Il semblerait que cette fonction prenne au moins 32 octets (un mot) de données d'appel.

DécalageOpcodeBase
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Si elle n'obtient pas les données d'appel, la transaction est annulée sans aucune donnée de retour.

Voyons ce qui se passe si la fonction obtient les données d'appel dont elle a besoin.

DécalageOpcodeBase
1A0JUMPDEST0x00 0x04 CALLDATASIZE 0x0153 0xDA
1A1POP0x04 CALLDATASIZE 0x0153 0xDA
1A2CALLDATALOADcalldataload(4) CALLDATASIZE 0x0153 0xDA

calldataload(4) est le premier mot des données d'appel après la signature de la méthode

DécalageOpcodeBase
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
173SLOADStockage[4] calldataload(4) 0x04 calldataload(4) 0xDA
174DUP2calldataload(4) Stockage[4] calldataload(4) 0x04 calldataload(4) 0xDA
175LTcalldataload(4)<Stockage[4] calldataload(4) 0x04 calldataload(4) 0xDA
176PUSH2 0x017e0x017EC calldataload(4)<Stockage[4] calldataload(4) 0x04 calldataload(4) 0xDA
179JUMPIcalldataload(4) 0x04 calldataload(4) 0xDA

Si le premier mot n'est pas inférieur à Stockage[4], la fonction échoue. Elle annule sans valeur retournée :

DécalageOpcodeBase
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

Si le calldataload(4) est inférieur à Stockage[4], nous obtenons ce code :

DécalageOpcodeBase
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

Et les emplacements mémoire 0x00-0x1F contiennent maintenant les données 0x04 (0x00-0x1E sont tous des zéros, 0x1F est quatre).

DécalageOpcodeBase
184PUSH1 0x200x20 calldataload(4) 0x00 calldataload(4) 0xDA
186SWAP1calldataload(4) 0x20 0x00 calldataload(4) 0xDA
187SWAP20x00 0x20 calldataload(4) calldataload(4) 0xDA
188SHA3(((SHA3 de 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA
189ADD(((SHA3 de 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA
18ASLOADStockage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] calldataload(4) 0xDA

Il y a donc une table de recherche dans le stockage, qui commence à SHA3 de 0x000...0004 et a une entrée pour chaque valeur de données d'appel légitime (valeur inférieure à Stockage[4]).

DécalageOpcodeBase
18BSWAP1calldataload(4) Stockage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA
18CPOPStockage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA
18DDUP20xDA Stockage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA
18EJUMPStockage[(((SHA3 de 0x00-0x1F))) + calldataload(4)] 0xDA

Nous savons déjà ce que fait le code au décalage 0xDA : il renvoie la valeur supérieure de la pile à l'appelant. Cette fonction renvoie donc la valeur de la table de recherche à l'appelant.

0x1f135823

Le code aux décalages 0xC4-0xCF est identique à ce que nous avons vu en 0x103-0x10E dans splitter() (autre que la destination JUMPI), donc nous savons que cette fonction n'est pas non plus payable.

DécalageOpcodeBase
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValue* 0xDA
D8DUP20xDA Value* 0xDA
D9JUMPValue* 0xDA

Nous savons déjà ce que fait le code au décalage 0xDA : il renvoie la valeur supérieure de la pile à l'appelant. Cette fonction retourne donc Value*.

Résumé de la méthode

Avez-vous l'impression de comprendre le contrat à ce stade ? Moi, non. Jusqu'à présent, nous disposons de ces méthodes :

MéthodeSignification
TransférerAccepter la valeur fournie par l'appel et augmenter Value* de ce montant
splitter()Retourner Stockage[3], l'adresse du proxy
currentWindow()Retourner Stockage[1]
merkleRoot()Retourner Stockage[0]
0x81e580d3Retourner la valeur d'une table de recherche, à condition que le paramètre soit inférieur à Stockage[4]
0x1f135823Retourner Stockage[6], alias Valeur*

Mais nous savons que toute autre fonctionnalité est fournie par le contrat dans Stockage[3]. Peut-être que si nous savions quel est ce contrat, cela nous donnerait un indice. Heureusement, il s'agit de la blockchain et tout est connu, du moins en théorie. Nous n'avons pas vu de méthode qui définisse Stockage[3], il doit donc avoir été défini par le constructeur.

Le constructeur

Quand nous examinons un contrat (opens in a new tab), nous pouvons aussi voir la transaction qui l'a créé.

Cliquez sur la transaction de création

Si nous cliquons sur cette transaction, puis sur l'onglet State, nous pouvons voir les valeurs initiales des paramètres. Plus précisément, nous pouvons voir que Stockage[3] contient 0x2f81e57ff4f4d83b40a9f719fd892d8e806e0761 (opens in a new tab). Ce contrat doit contenir la fonctionnalité manquante. Nous pouvons le comprendre en utilisant les mêmes outils que ceux que nous avons utilisés pour le contrat que nous étudions.

Le contrat proxy

En utilisant les mêmes techniques que celles que nous avons utilisées pour le contrat original ci-dessus, nous pouvons voir que le contrat est annulé si :

  • Il y a des ETH attachés à l'appel (0x05-0x0F)
  • La taille des données d'appel est inférieure à quatre (0x10-0x19 et 0xBE-0xC2)

Et les méthodes qu'il prend en charge sont :

Nous pouvons ignorer les quatre dernières méthodes, car nous ne les atteindrons jamais. Leurs signatures sont telles que notre contrat original s'en occupe lui-même (vous pouvez cliquer sur les signatures pour voir les détails ci-dessus), donc il doit s'agir de méthodes qui sont surchargées (opens in a new tab).

L'une des méthodes restantes est claim(<params>) et une autre est isClaimed(<params>), donc cela ressemble à un contrat d'airdrop. Une des méthodes restantes est claim(<params>), et une autre est isClaimed(<params>), donc cela ressemble à un contrat d'airdrop. Au lieu de passer en revue le reste des opcodes un par un, nous pouvons essayer le décompilateur (opens in a new tab), qui produit des résultats utilisables pour trois fonctions de ce contrat.

L'ingénierie inverse des autres est laissée comme exercice au lecteur.

scaleAmountByPercentage

Voici ce que le décompilateur nous donne pour cette fonction :

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)

Le premier require teste si les données d'appel contiennent, en plus des quatre octets de la signature de fonction, au moins 64 octets, ce qui est suffisant pour les deux paramètres. Sinon, il y a manifestement un problème.

L'instruction if semble vérifier que _param1 n'est pas nul et que _param1 * _param2 n'est pas négatif. C'est probablement pour éviter les cas de bouclage (wrap around).

Enfin, la fonction retourne une valeur mise à l'échelle.

claim

Le code que le décompilateur crée est complexe, et tout n'est pas pertinent pour nous. Je vais en sauter une partie pour me concentrer sur les lignes qui, à mon avis, fournissent des informations utiles.

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'

Nous voyons ici deux choses importantes :

  • _param2, bien qu'il soit déclaré comme un uint256, est en fait une adresse
  • _param1 est la fenêtre réclamée, qui doit être currentWindow ou une fenêtre antérieure.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'

Nous savons donc maintenant que Stockage[5] est un tableau de fenêtres et d'adresses, et qu'il indique si l'adresse a réclamé la récompense pour cette fenêtre.

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'
Afficher tout

Nous savons que unknown2eb4a7ab est en fait la fonction merkleRoot(), ce code semble donc vérifier une preuve de Merkle (opens in a new tab). Cela signifie que _param4 est une preuve de Merkle.

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

C'est ainsi qu'un contrat transfère ses propres ETH à une autre adresse (contrat ou compte externe). Il l'appelle avec une valeur qui est le montant à transférer. Il semble donc qu'il s'agisse d'un airdrop d'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

Les deux dernières lignes nous disent que Stockage[2] est aussi un contrat que nous appelons. Si nous examinons la transaction du constructeur (opens in a new tab), nous voyons que ce contrat est 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), un contrat Wrapped Ether dont le code source a été téléversé sur Etherscan (opens in a new tab).

Il semble donc que les contrats tentent d'envoyer des ETH à _param2. S'il peut le faire, tant mieux. Sinon, il tente d'envoyer du WETH (opens in a new tab). Si _param2 est un compte externe (EOA), il peut toujours recevoir des ETH, mais les contrats peuvent refuser de recevoir des ETH. Cependant, le WETH est un ERC-20 et les contrats ne peuvent pas refuser de l'accepter.

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

À la fin de la fonction, nous voyons qu'une entrée de journal est générée. Examinez les entrées de journal générées (opens in a new tab) et filtrez sur le sujet qui commence par 0xdbd5.... Si nous cliquons sur l'une des transactions qui ont généré une telle entrée (opens in a new tab), nous voyons qu'il s'agit bien d'une réclamation : le compte a envoyé un message au contrat sur lequel nous faisons de l'ingénierie inverse et a reçu de l'ETH en retour.

Une transaction de réclamation

1e7df9d3

Cette fonction est très similaire à claim ci-dessus. Elle vérifie également une preuve de Merkle, tente de transférer de l'ETH à la première et produit le même type d'entrée de journal.

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)
Afficher tout

La principale différence est que le premier paramètre, la fenêtre à retirer, n'est pas là. À la place, il y a une boucle sur toutes les fenêtres qui pourraient être réclamées.

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
Afficher tout

Cela ressemble donc à une variante de claim qui réclame toutes les fenêtres.

Conclusion

À présent, vous devriez savoir comment comprendre les contrats dont le code source n'est pas disponible, en utilisant soit les opcodes, soit (quand cela fonctionne) le décompilateur. Comme le montre la longueur de cet article, l'ingénierie inverse d'un contrat n'est pas triviale, mais dans un système où la sécurité est essentielle, c'est une compétence importante de pouvoir vérifier que les contrats fonctionnent comme promis.

Voir ici pour plus de mon travail (opens in a new tab).

Dernière mise à jour de la page : 22 août 2025

Ce tutoriel vous a été utile ?