Passer au contenu principal

Ingénierie inverse d'un contrat

evmcodes d'opérations
Avancé
Ori Pomerantz
30 décembre 2021
33 minutes de lecture minute read

Introduction

Il n'y a aucun secret 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). Or ce n'est pas toujours le cas(opens in a new tab). Dans cet article, vous apprendrez comment rétro-concevoir des contrats en prenant comme exemple un contrat sans code source, 0x2510c039cc3b061d79e564b38836da87e31b342f(opens in a new tab).

Il existe des décompilateurs, mais ils ne produisent pas toujours des résultats utilisables(opens in a new tab). Dans cet article, vous apprendrez comment rétro-concevoir manuellement et comprendre un contrat grâce aux opcodes(opens in a new tab) et également comment interpréter les résultats du décompilateur.

Pour être en mesure de comprendre cet article, vous devriez connaître les bases de l'EVM et être au moins familier avec l'assembleur EVM. Vous pouvez en savoir plus sur ces sujets ici(opens in a new tab).

Préparer le Code Exécutable

Vous pouvez récupérer les opcodes en cherchant le contrat sur Etherscan, cliquez sur l'onglet Contract et puis sur Switch to Opcodes View. Vous obtenez un affichage d'un opcode par ligne.

Vue Opcode depuis Etherscan

Pour être capable de comprendre les sauts, vous devez savoir où se trouve chaque opcode dans le code. Pour ce faire, vous pouvez ouvrir Google Spreadsheet et coller les codes d'opération dans le colonne C.Vous pouvez sauter les étapes suivantes en faisant une copie de cette feuille de calcul(opens in a new tab).

L'étape suivante est d'obtenir les emplacements corrects dans le code pour comprendre les sauts. Nous allons mettre la taille du code d'opération dans la colonne B et son emplacement (en hexadécimal) dans la colonne A. Entrez cette fonction dans la cellule B1 et puis copiez-collez sur 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)

Tout d'abord, cette fonction ajoute un octet au code d'opération, puis elle recherche le mot clé PUSH. Les codes d'opération PUSH sont spéciaux car ils doivent avoir des octets supplémentaires pour la valeur qui doit être poussée. Si l'opcode est un PUSH, nous extrayons le nombre d'octets et ajoutons la valeur à la taille de l'opcode.

Dans la cellule A1, déclarez la première valeur décalée à 0. Puis, dans la cellule A2, entrez cette fonction et copiez-collez la sur 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 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 première partie du code :

DécalageOpcodePile (après le code d'opération)
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 sous la forme d'une valeur de 32 octets sur des emplacements de mémoire 0x40-0x5F (0x80 est stocké dans 0x5F, et 0x40-0x5E sont à zéro).
  2. Lire la taille des données d'appel. Normalement, les données d'appel pour un contrat Ethereum suivent l'ABI (interface binaire-programme)(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, il y a un saut à 0x5E.

Organigramme de cette partie

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

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

Ce snippet commence avec un JUMPDEST. Les programmes EVM (machine virtuelle Ethereum) lèvent une exception si l'on saute à un code d'opération qui n'est pas JUMPDEST. Puis, il vérifie le CALLDATASIZE et si c'est « true » (c'est-à-dire, si ce n'est pas zéro), il saute à 0x7C. Nous verrons ça 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
6BSLOADStorage[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 aucune donnée d'appel. Les transactions qui transfèrent juste de l'ETH sans aucune donnée d'appel (et donc aucune méthode) disposent de la méthode Transfer dans Etherscan. En fait, la toute première transaction reçue par le contrat(opens in a new tab) est un transfert.

Si nous regardons la transaction et que nous cliquons sur Click to see More, nous voyons que l'appel de donnée, appelé une donnée d'entrée, est en fait vide (0x). Notez aussi que la valeur est à 1.559 ETH, ce qui sera intéressant plus tard.

Les données d'appel sont vides

Ensuite, cliquez sur l'onglet State et développez le contrat que nous rétro-concevons (0x2510...). Vous pouvez voir que Stockage[6] a changé pendant la transaction, et si vous changez l'hexadécimal en Numéro, vous voyez que la valeur transférée est maintenant affichée en wei : 1,559,000,000,000,000 (j'ai ajouté les virgules à des fins de clarté), correspondant à la valeur du prochain contrat.

Le changement dans Stockage[6]

Si nous regardons dans 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 Storage[6] a suivi la valeur du contrat pendant un certain temps. Pour le moment, nous l'appellerons Valeur*. L'astérisque (*) nous rappelle que nous ne savons pas ce que cette variable fait pour le moment, mais ça ne peut pas être simplement de tracer la valeur du contrat parce qu'il n'y a pas besoin d'utiliser le stockage, qui est très cher, quand vous pouvez obtenir le solde de vos comptes à l'aide de ADDRESS BALANCE. Le premier code d'opérations dévoile la propre adresse du contrat. Le deuxième lit l'adresse en haut de la pile et la remplace par le solde de cette adresse.

DécalageOpcodePile
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écalageOpcodePile
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 au niveau des bits donc il inverse la valeur de chaque bit dans la valeur d'appel.

DécalageOpcodePile
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 inférieure à 2^256-CALLVALUE-1 ou égale à celle-ci. Cela ressemble à une logique pour éviter les dépassements. Et en effet, nous voyons qu'après quelques opérations absurdes (écrire en mémoire est sur le point d'être supprimé, par exemple) au décalage 0x01DE, le contrat annule si le dépassement est détecté, ce qui est le comportement normal.

Notez qu'un tel dépassement est extrêmement improbable, parce qu'il nécessiterait une valeur d'appel et Value* d'être d'un ordre de grandeur de 2^256 wei, environ 10^59 ETH. L'offre d'ETH total, au moment ou l'on écrit ceci, est inférieur à deux cents millions(opens in a new tab).

DécalageOpcodePile
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, obtenons Value* + CALLVALUE et sautons au décalage 0x75.

DécalageOpcodePile
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 sur ce que les transactions Transfer font.

DécalageOpcode
79POP
7APOP
7BSTOP

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

Pour tout résumer, voici un diagramme du code initial.

Organigramme des points d'entrée

Le gestionnaire à 0x7C

J'ai sciemment omis de mettre dans la rubrique ce que fait ce gestionnaire. Le but n'est pas de vous enseigner comment fonctionne ce contrat spécifique, mais comment rétro-concevoir les contrats. Vous apprendrez ce qu'il fait de la même manière que moi, en suivant le code.

Nous arrivons ici de plusieurs façons :

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

Il s'agit d'une autre cellule de stockage, une cellule que je n'ai pas trouvée dans aucune transaction, donc il est plus difficile de savoir ce que cela signifie. Le code ci-dessous clarifiera la question.

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

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

DécalageOpcodePile
9BSWAP10x9D Storage[3]-as-address 0x00
9CJUMPStorage[3]-as-address 0x00

Ce saut est superflu puisque nous allons au prochain code d'opérations. Ce code n'est pas aussi efficace en gaz qu'il pourrait l'être.

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

Au tout début du code, nous définissons Mem[0x40] à 0x80. Si nous cherchons 0x40 plus tard, nous voyons que nous ne le changeons pas - nous pouvons donc supposer qu'il est 0x80.

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

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

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

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 travail réel. 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 d'appel sont :

  • Gaz : Tout le gaz restant
  • Adresse appelée : Stockage[3] comme adresse
  • Données d'appel : Les octets CALLDATASIZE commençant à 0x80, où nous avons mis les données d'appel d'origine
  • Données de retour : Aucune (0x00 - 0x00), nous allons obtenir les données de retour par d'autres moyens (voir ci-dessous)
DécalageOpcodePile
B0RETURNDATASIZERETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B1DUP1RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B5RETURNDATACOPYRETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address

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

DécalageOpcodePile
B6DUP2(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B7DUP1(((call success/failure))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B8ISZERO(((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
B9PUSH2 0x00c00xC0 (((did the call fail))) (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BCJUMPI(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BDDUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BEDUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
BFRETOUR

Donc, après l'appel, nous copions les données retournées dans le tampon 0x80 - 0x80+RETURNDATASIZE, et si l'appel réussit, nous retournons RETURN avec exactement ce tampon.

Échec de DELEGATECALL

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

DécalageOpcodePile
C0JUMPDEST(((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C1DUP2RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C2DUP50x80 RETURNDATASIZE (((call success/failure))) RETURNDATASIZE (((call success/failure))) 0x80 Storage[3]-as-address
C3REVERT

Donc nous annulons REVERT avec le même tampon que nous avons utilisé pour RETURN précédemment : 0x80 - 0x80+RETURNDATASIZE

Organigramme de l'appel de proxy

Appels ABI

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

DécalageOpcodePile
DPUSH1 0x000x00
FCALLDATALOAD(((First word (256 bits) of the call data)))
10PUSH1 0xe00xE0 (((First word (256 bits) of the call data)))
12SHR(((first 32 bits (4 bytes) of the call data)))

Etherscan nous dit que 1C est un code d'opération inconnu, parce qu'il a été ajouté après que Etherscan ait écrit cette fonctionnalité(opens in a new tab) et qu'ils ne l'ont pas mise à jour. Un tableau d'opcode à jour(opens in a new tab) nous montre que c'est un décalage à droite

DécalageOpcodePile
13DUP1(((first 32 bits (4 bytes) of the call data))) (((first 32 bits (4 bytes) of the call data)))
14PUSH4 0x3cd8045e0x3CD8045E (((first 32 bits (4 bytes) of the call data))) (((first 32 bits (4 bytes) of the call data)))
19GT0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1APUSH2 0x00430x43 0x3CD8045E>first-32-bits-of-the-call-data (((first 32 bits (4 bytes) of the call data)))
1DJUMPI(((first 32 bits (4 bytes) of the call data)))

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

MéthodeSignature de la méthodeDécalage vers lequel sauter
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 proxy ait une correspondance.

Organigramme des appels ABI

splitter()

DécalageOpcodePile
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 pas envoyé d'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 REVERT pour éviter d'avoir cet ETH où il ne peut le récupérer.

DécalageOpcodePile
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] a.k.a the contract for which we are a proxy)))
114PUSH1 0x400x40 (((Storage[3] a.k.a the contract for which we are a proxy)))
116MLOAD0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] a.k.a the contract for which we are a proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] a.k.a the contract for which we are a proxy)))
12DSWAP2(((Storage[3] a.k.a the contract for which we are a proxy))) 0xFF...FF 0x80
12EANDProxyAddr 0x80
12FDUP20x80 ProxyAddr 0x80
130MSTORE0x80

Et 0x80 contient maintenant l'adresse du proxy

DécalageOpcodePile
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 allons donc appeler la valeur dans la pile X, et n'oubliez pas que dans la fonction splitter() la valeur de ce X est 0xA0.

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

Ce code reçoit un pointeur de mémoire dans la pile (X), et entraîne le contrat à RETURN avec un tampon qui est 0x80 - X.

Dans le cas de splitter(), ceci retourne l'adresse pour laquelle nous sommes un proxy. RETURN retourne le tampon en 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 payable non plus.

DécalageOpcodePile
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[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 n'oubliez pas que dans la fonction currentWindow() la valeur de ce Y est Stockage[1].

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

Écrire Y à 0x80-0x9F.

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

Et le reste est déjà expliqué au-dessus. Donc les sauts à 0xDA écrivent la pile supérieure (Y) à 0x80-0x9F, et retournent 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 payable non plus.

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

Nous avons déjà compris ce qu'il se passe après le saut. 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 payable non plus.

DécalageOpcodePile
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écalageOpcodePile
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Si elle ne récupère pas les données d'appel, la transaction est annulée sans aucune donnée retournée.

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

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

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

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

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

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

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

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

Nous savons déjà ce que fait le code à la valeur décalée 0xDA , il retourne la valeur la plus élevée de la pile à l'appelant. Ainsi, cette fonction retourne 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 payable non plus.

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

Nous savons déjà ce que fait le code à la position 0xDA , il retourne la valeur la plus élevée de la pile à l'appelant. Donc cette fonction retourne Value*.

Résumé de la méthode

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

MéthodeSignification
TransférerAccepte la valeur fournie par l'appel et augmente Value* de ce montant
splitter()Return Storage[3], the proxy address
currentWindow()Return Storage[1]
merkleRoot()Return Storage[0]
0x81e580d3Retourne la valeur d'une table de recherche, à condition que le paramètre soit inférieur à Stockage[4]
0x1f135823Return Storage[6], a.k.a. Value*

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, ceci est la blockchain et tout est connu, du moins en théorie. Nous n'avons pas vu de méthode qui définisse Stockage[3], donc il doit avoir été défini par le constructeur.

Le Constructeur

Lorsque nous regardons un contrat(opens in a new tab) nous pouvons également voir la transaction qui l'a créée.

Cliquez sur la transaction de création

Si nous cliquons sur cette transaction, puis sur l'onglet État , 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 utilisés pour le contrat que nous étudions.

Le contrat de proxy

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

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

Et que les méthodes qu'il supporte sont :

MéthodeSignature de la méthodeDécalage vers lequel sauter
scaleAmountByPercentage(uint256,uint256)(opens in a new tab)0x8ffb5c970x0135
isClaimed(uint256,address)(opens in a new tab)0xd2ef07950x0151
claim(uint256,address,uint256,bytes32[])(opens in a new tab)0x2e7ba6ef0x00F4
incrementWindow()(opens in a new tab)0x338b1d310x0110
???0x3f26479e0x0118
???0x1e7df9d30x00C3
currentWindow()(opens in a new tab)0xba0bafb40x0148
merkleRoot()(opens in a new tab)0x2eb4a7ab0x0107
???0x81e580d30x0122
???0x1f1358230x00D8

Nous pouvons ignorer les quatre dernières méthodes parce que nous ne les atteindrons jamais. Leurs signatures sont telles que notre contrat original les prend en charge par lui-même (vous pouvez cliquer sur les signatures pour voir les détails ci-dessus), donc elles doivent être des méthodes qui sont contournées(opens in a new tab).

Une des méthodes restantes est claim(<params>), et une autre est isClaimed(<params>), donc cela ressemble à un contrat d'airdrop. Au lieu de fouiller le code d'opération restant par opcode, nous pouvons essayer le décompilateur(opens in a new tab), qui donne des résultats utilisables pour trois fonctions de ce contrat. La rétro-conception des autres est laissée au lecteur comme exercice de travail.

scaleAmountByPercentage

Voilà ce que nous donne le décompilateur 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)
Copier

Le premier require vérifie que les données d'appel ont, en plus des quatre octets de la signature de la fonction, au moins 64 octets, assez pour les deux paramètres. Si ce n'est pas le cas, il y a évidemment quelque chose qui ne va pas.

L'instruction if semble vérifier que _param1 n'est pas zéro et que _param1 * _param2 n'est pas négatif. C'est probablement pour éviter des cas de renvoi à la ligne.

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 passer une partie pour me concentrer sur les lignes qui je pense 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'
Copier

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 revendiquée, qui doit être currentWindow ou antérieure.
1 ...
2 if stor5[_claimWindow][addr(_claimFor)]:
3 revert with 0, 'Account already claimed the given window'
Copier

Nous savons donc maintenant que Stockage[5] est un tableau de fenêtres et d'adresses, et 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
Copier

Nous savons que unknown2eb4a7ab est en fait la fonction merkleRoot(), donc ce code semble 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
Copier

C’est ainsi qu’un contrat transfère son propre ETH à une autre adresse (contrat ou propriété externe). Il l'appelle avec une valeur qui est le montant à transférer. On dirait donc qu'il s'agit 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
Copier

Les deux dernières lignes nous disent que Stockage[2] est également un contrat que nous appelons. Si nous regardons la transaction constructeur(opens in a new tab) nous voyons que ce contrat est 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2(opens in a new tab), un contrat de Wrapped Etherdont le code source a été téléchargé sur Etherscan(opens in a new tab).

Il semble donc que les contrats tentent d'envoyer de l'ETH à _param2. S'ils peuvent le faire, très bien. Sinon, il tente d'envoyer WETH(opens in a new tab). Si _param2 est un compte externe (EOA) alors il peut toujours recevoir de l'ETH, mais les contrats peuvent refuser de recevoir de l'ETH. Cependant, WETH est 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)
Copier

À la fin de la fonction, nous voyons qu'une entrée de journal est générée. Regardez 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'effectivement cela ressemble à une demande - le compte a envoyé un message au contrat que nous rétro-concevons 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 au premier, 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
Copier

La principale différence est que le premier paramètre, la fenêtre du retrait, n'est pas là. Au lieu de cela, 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
Copier

Donc elle ressemble à 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 codes d'opérations soit (quand cela fonctionne) le décompilateur. Comme le montre la longueur de cet article, rétro-concevoir un contrat n'est pas trivial, mais dans un système où la sécurité est essentielle, il est important d'être capable de vérifier que les contrats fonctionnent comme promis.

Dernière modification: @wackerow(opens in a new tab), 2 avril 2024

Ce tutoriel vous a été utile ?