Passer au contenu principal

Rétro-ingénierie d'un contrat

evm
codes d'opération
Avancé
Ori Pomerantz
30 décembre 2021
33 minutes de lecture

Introduction

Il n'y a pas de secrets sur la chaîne de blocs, tout ce qui s'y passe est cohérent, vérifiable et accessible publiquement. 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 comment faire la rétro-ingénierie de contrats en examinant 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 exploitables (opens in a new tab). Dans cet article, vous apprendrez comment faire manuellement la rétro-ingénierie et comprendre un contrat à partir des codes d'opération (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 un peu familier avec l'assembleur EVM. Vous pouvez en apprendre davantage sur ces sujets ici (opens in a new tab).

Préparer le code exécutable

Vous pouvez obtenir les codes d'opération 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 code d'opération par ligne.

Opcode View from Etherscan

Cependant, pour pouvoir comprendre les sauts, vous devez savoir où se trouve chaque code d'opération dans le code. Pour ce faire, une méthode consiste à ouvrir une feuille de calcul Google (Google Spreadsheet) et à coller les codes d'opération dans la colonne C. Vous pouvez ignorer 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 corrects du code afin de pouvoir comprendre les sauts. Nous allons placer la taille du code d'opération 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 avoir fait cela, vous pouvez masquer la colonne B.

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

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

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

=dec2hex(hex2dec(A1)+B1)

Nous avons besoin que cette fonction nous donne la valeur hexadécimale car les valeurs qui sont empilé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. Voici la partie initiale du code :

DécalageCode d'opérationPile (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. Écrire 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. 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, sauter à 0x5E.

Flowchart for this portion

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

DécalageCode d'opération
5EJUMPDEST
5FCALLDATASIZE
60PUSH2 0x007c
63JUMPI

Cet extrait commence par un JUMPDEST. Les programmes de l'EVM (machine virtuelle Ethereum) lèvent une exception si vous sautez vers un code d'opération qui n'est pas JUMPDEST. Ensuite, il examine la CALLDATASIZE, et si elle est « vraie » (c'est-à-dire non nulle), il saute à 0x7C. Nous y reviendrons plus bas.

DécalageCode d'opérationPile (après le code d'opération)
64CALLVALUE fourni par l'appel. Appelé msg.value en Solidity
65PUSH1 0x066 CALLVALUE
67PUSH1 0x000 6 CALLVALUE
69DUP3CALLVALUE 0 6 CALLVALUE
6ADUP36 CALLVALUE 0 6 CALLVALUE
6BSLOADStorage[6] CALLVALUE 0 6 CALLVALUE

Donc, lorsqu'il n'y a pas de données d'appel, nous lisons la valeur de Storage[6]. Nous ne savons pas encore ce qu'est cette valeur, mais nous pouvons chercher des transactions que le contrat a reçues sans données d'appel. Les transactions qui transfèrent simplement de l'ETH sans aucune donnée 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 regardons dans cette transaction et cliquons sur Click to see More (Cliquer pour voir plus), nous voyons que les données d'appel, appelées données d'entrée (input data), sont en effet vides (0x). Remarquez également que la valeur est de 1,559 ETH, ce qui sera pertinent plus tard.

The call data is empty

Ensuite, cliquez sur l'onglet State (État) et développez le contrat que nous sommes en train de rétro-ingénierer (0x2510...). Vous pouvez voir que Storage[6] a bien changé pendant la transaction, et si vous changez Hex en Number (Nombre), vous voyez qu'il est devenu 1 559 000 000 000 000 000, la valeur transférée en wei (j'ai ajouté les espaces pour plus de clarté), correspondant à la valeur suivante du contrat.

Le changement dans Storage[6]

Si nous regardons 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 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, quand vous pouvez obtenir le solde de vos comptes en utilisant ADDRESS BALANCE. Le premier code d'opération pousse la propre adresse du contrat. Le second lit l'adresse au sommet de la pile et la remplace par le solde de cette adresse.

DécalageCode d'opérationPile
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 allons continuer à tracer ce code à la destination du saut.

DécalageCode d'opérationPile
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 du bit (bitwise), il inverse donc la valeur de chaque bit dans la valeur d'appel.

DécalageCode d'opérationPile
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

Nous sautons si Value* est inférieur ou égal à 2^256-CALLVALUE-1. Cela ressemble à une logique pour empêcher un dépassement de capacité (overflow). Et en effet, nous voyons qu'après quelques opérations dénuées de sens (l'écriture en mémoire est sur le point d'être supprimée, par exemple) au décalage 0x01DE, le contrat s'annule 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 nécessiterait 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, est inférieure à deux cents millions (opens in a new tab).

DécalageCode d'opérationPile
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écalageCode d'opérationPile
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. Cela est cohérent avec ce que nous disons que les transactions Transfer font.

DécalageCode d'opération
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 pour le code initial.

Entry point flowchart

Le gestionnaire à 0x7C

Je n'ai volontairement pas indiqué dans le titre ce que fait ce gestionnaire. Le but n'est pas de vous apprendre comment fonctionne ce contrat spécifique, mais comment faire de la rétro-ingénierie de 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 (à partir du décalage 0x63)
  • Si la signature de la méthode est inconnue (à partir des décalages 0x42 et 0x5D)
DécalageCode d'opérationPile
7CJUMPDEST
7DPUSH1 0x000x00
7FPUSH2 0x009d0x9D 0x00
82PUSH1 0x030x03 0x9D 0x00
84SLOADStorage[3] 0x9D 0x00

Il s'agit d'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 rendra les choses plus claires.

DécalageCode d'opérationPile
85PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xff....ff Storage[3] 0x9D 0x00
9AANDStorage[3]-en-adresse 0x9D 0x00

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

DécalageCode d'opérationPile
9BSWAP10x9D Storage[3]-en-adresse 0x00
9CJUMPStorage[3]-en-adresse 0x00

Ce saut est superflu, puisque nous passons au code d'opération suivant. Ce code est loin d'être aussi efficace en gaz qu'il pourrait l'être.

DécalageCode d'opérationPile
9DJUMPDESTStorage[3]-en-adresse 0x00
9ESWAP10x00 Storage[3]-en-adresse
9FPOPStorage[3]-en-adresse
A0PUSH1 0x400x40 Storage[3]-en-adresse
A2MLOADMem[0x40] Storage[3]-en-adresse

Tout au début du code, nous avons défini Mem[0x40] à 0x80. Si nous cherchons 0x40 plus tard, nous voyons que nous ne le modifions pas - nous pouvons donc supposer qu'il vaut 0x80.

DécalageCode d'opérationPile
A3CALLDATASIZECALLDATASIZE 0x80 Storage[3]-en-adresse
A4PUSH1 0x000x00 CALLDATASIZE 0x80 Storage[3]-en-adresse
A6DUP30x80 0x00 CALLDATASIZE 0x80 Storage[3]-en-adresse
A7CALLDATACOPY0x80 Storage[3]-en-adresse

Copie toutes les données d'appel en mémoire, en commençant à 0x80.

DécalageCode d'opérationPile
A8PUSH1 0x000x00 0x80 Storage[3]-en-adresse
AADUP10x00 0x00 0x80 Storage[3]-en-adresse
ABCALLDATASIZECALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse
ACDUP40x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse
ADDUP6Storage[3]-en-adresse 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse
AEGASGAS Storage[3]-en-adresse 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse
AFDELEGATE_CALL

Maintenant, les choses sont beaucoup plus claires. Ce contrat peut agir comme un contrat proxy (opens in a new tab), appelant l'adresse dans Storage[3] pour faire le vrai travail. DELEGATE_CALL appelle un contrat distinct, 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 : Storage[3]-en-adresse
  • Données d'appel : Les octets CALLDATASIZE commençant à 0x80, là où nous avons placé 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écalageCode d'opérationPile
B0RETURNDATASIZERETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
B1DUP1RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
B2PUSH1 0x000x00 RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
B4DUP50x80 0x00 RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
B5RETURNDATACOPYRETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse

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

DécalageCode d'opérationPile
B6DUP2(((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
B7DUP1(((succès/échec de l'appel))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
B8ISZERO(((l'appel a-t-il échoué))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
B9PUSH2 0x00c00xC0 (((l'appel a-t-il échoué))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
BCJUMPI(((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
BDDUP2RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
BEDUP50x80 RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
BFRETURN

Donc, 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 alors 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 renvoyer les mêmes données et également annuler.

DécalageCode d'opérationPile
C0JUMPDEST(((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
C1DUP2RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
C2DUP50x80 RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse
C3REVERT

Nous exécutons donc REVERT avec le même tampon que celui utilisé pour RETURN plus tôt : 0x80 - 0x80+RETURNDATASIZE

Call to proxy flowchart

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écalageCode d'opérationPile
DPUSH1 0x000x00
FCALLDATALOAD(((Premier mot (256 bits) des données d'appel)))
10PUSH1 0xe00xE0 (((Premier mot (256 bits) des données d'appel)))
12SHR(((les 32 premiers bits (4 octets) des données d'appel)))

Etherscan nous indique que 1C est un code d'opération inconnu, car il a été ajouté après qu'Etherscan a écrit cette fonctionnalité (opens in a new tab) et ils ne l'ont pas mise à jour. Un tableau des codes d'opération à jour (opens in a new tab) nous montre qu'il s'agit d'un décalage vers la droite

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

Diviser ainsi en deux les tests de correspondance de la signature de méthode permet d'économiser la moitié des tests en moyenne. Le code qui suit immédiatement ceci et le code en 0x43 suivent le même modèle : DUP1 les 32 premiers bits des données d'appel, PUSH4 (((method signature>, exécutent 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 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 un proxy ait une correspondance.

ABI calls flowchart

splitter()

DécalageCode d'opérationPile
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é de l'ETH, cela doit être une erreur et nous voulons REVERT pour éviter que cet ETH ne se retrouve là où il ne peut pas être récupéré.

DécalageCode d'opérationPile
10FJUMPDEST
110POP
111PUSH1 0x030x03
113SLOAD(((Storage[3] alias le contrat pour lequel nous sommes un proxy)))
114PUSH1 0x400x40 (((Storage[3] alias le contrat pour lequel nous sommes un proxy)))
116MLOAD0x80 (((Storage[3] alias le contrat pour lequel nous sommes un proxy)))
117PUSH20 0xffffffffffffffffffffffffffffffffffffffff0xFF...FF 0x80 (((Storage[3] alias le contrat pour lequel nous sommes un proxy)))
12CSWAP10x80 0xFF...FF (((Storage[3] alias le contrat pour lequel nous sommes un proxy)))
12DSWAP2(((Storage[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écalageCode d'opérationPile
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 rappelez-vous simplement que dans splitter(), la valeur de ce X est 0xA0.

DécalageCode d'opérationPile
E4JUMPDESTX
E5PUSH1 0x400x40 X
E7MLOAD0x80 X
E8DUP10x80 0x80 X
E9SWAP2X 0x80 0x80
EASUBX-0x80 0x80
EBSWAP10x80 X-0x80
ECRETURN

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

Dans le cas de splitter(), cela renvoie l'adresse pour laquelle nous sommes un proxy. RETURN renvoie le tampon dans 0x80-0x9F, qui est l'endroit 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() (à l'exception de la destination JUMPI), nous savons donc que currentWindow() n'est pas non plus payable.

DécalageCode d'opérationPile
164JUMPDEST
165POP
166PUSH2 0x00da0xDA
169PUSH1 0x010x01 0xDA
16BSLOADStorage[1] 0xDA
16CDUP20xDA Storage[1] 0xDA
16DJUMPStorage[1] 0xDA

Le code DA

Ce code est également partagé avec d'autres méthodes. Nous appellerons donc la valeur dans la pile Y, et rappelez-vous simplement que dans currentWindow(), la valeur de ce Y est Storage[1].

DécalageCode d'opérationPile
DAJUMPDESTY 0xDA
DBPUSH1 0x400x40 Y 0xDA
DDMLOAD0x80 Y 0xDA
DESWAP1Y 0x80 0xDA
DFDUP20x80 Y 0x80 0xDA
E0MSTORE0x80 0xDA

Écrire Y dans 0x80-0x9F.

DécalageCode d'opérationPile
E1PUSH1 0x200x20 0x80 0xDA
E3ADD0xA0 0xDA

Et le reste est déjà expliqué ci-dessus. Ainsi, les sauts vers 0xDA écrivent le sommet de la pile (Y) dans 0x80-0x9F, et renvoient cette valeur. Dans le cas de currentWindow(), cela renvoie Storage[1].

merkleRoot()

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

OffsetCode d'opérationPile
F9JUMPDEST
FAPOP
FBPUSH2 0x00da0xDA
FEPUSH1 0x000x00 0xDA
100SLOADStorage[0] 0xDA
101DUP20xDA Storage[0] 0xDA
102JUMPStorage[0] 0xDA

Ce qui se passe après le saut, nous l'avons déjà compris. Donc merkleRoot() renvoie Storage[0].

0x81e580d3

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

DécalageCode d'opérationPile
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 semble que cette fonction prenne au moins 32 octets (un mot) de données d'appel.

DécalageCode d'opérationPile
19DDUP10x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19EDUP20x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA
19FREVERT

Si elle ne reçoit 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 reçoit effectivement les données d'appel dont elle a besoin.

DécalageCode d'opérationPile
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écalageCode d'opérationPile
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 à Storage[4], la fonction échoue. Elle est annulée sans aucune valeur de retour :

DécalageCode d'opérationPile
17APUSH1 0x000x00 ...
17CDUP10x00 0x00 ...
17DREVERT

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

DécalageCode d'opérationPile
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 la donnée 0x04 (0x00-0x1E sont tous des zéros, 0x1F vaut quatre)

DécalageCode d'opérationPile
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 donc une table de correspondance dans le stockage, qui commence au SHA3 de 0x000...0004 et possède une entrée pour chaque valeur légitime de données d'appel (valeur inférieure à Storage[4]).

DécalageCode d'opérationPile
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 au décalage 0xDA, il renvoie la valeur au sommet de la pile à l'appelant. Cette fonction renvoie donc la valeur de la table de correspondance à l'appelant.

0x1f135823

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

DécalageCode d'opérationPile
D0JUMPDEST
D1POP
D2PUSH2 0x00da0xDA
D5PUSH1 0x060x06 0xDA
D7SLOADValeur* 0xDA
D8DUP20xDA Valeur* 0xDA
D9JUMPValeur* 0xDA

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

Résumé des méthodes

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

MéthodeSignification
TransfertAccepter la valeur fournie par l'appel et augmenter Value* de ce montant
splitter()Renvoyer Storage[3], l'adresse du proxy
currentWindow()Renvoyer Storage[1]
merkleRoot()Renvoyer Storage[0]
0x81e580d3Renvoyer la valeur d'une table de correspondance, à condition que le paramètre soit inférieur à Storage[4]
0x1f135823Renvoyer Storage[6], alias Valeur*

Mais nous savons que toute autre fonctionnalité est fournie par le contrat dans Storage[3]. Peut-être que si nous savions ce qu'est ce contrat, cela nous donnerait un indice. Heureusement, c'est la chaîne de blocs et tout est connu, du moins en théorie. Nous n'avons vu aucune méthode qui définit Storage[3], il a donc dû être 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éé.

Click the create transaction

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 Storage[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 examinons.

Le contrat proxy

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

  • Il y a de l'ETH attaché à 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 prend en charge sont :

Nous pouvons ignorer les quatre méthodes du bas car nous ne les atteindrons jamais. Leurs signatures sont telles que notre contrat original s'en charge lui-même (vous pouvez cliquer sur les signatures pour voir les détails ci-dessus), ce doivent donc être des méthodes qui sont remplacées (opens in a new tab).

L'une des méthodes restantes est claim(<params>), et une autre est isClaimed(<params>), il semble donc s'agir d'un contrat d'airdrop. Au lieu de parcourir le reste code d'opération par code d'opération, nous pouvons essayer le décompilateur (opens in a new tab), qui produit des résultats utilisables pour trois fonctions de ce contrat. La rétro-ingénierie des autres est laissée comme exercice au lecteur.

scaleAmountByPercentage

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

def unknown8ffb5c97(uint256 _param1, uint256 _param2) payable:
  require calldata.size - 4 >=64
  if _param1 and _param2 > -1 / _param1:
      revert with 0, 17
  return (_param1 * _param2 / 100 * 10^6)

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

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 les cas de dépassement de capacité.

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

claim

Le code créé par le décompilateur est complexe, et tout n'est pas pertinent pour nous. Je vais en ignorer une partie pour me concentrer sur les lignes qui, selon moi, fournissent des informations utiles.

def unknown2e7ba6ef(uint256 _param1, uint256 _param2, uint256 _param3, array _param4) payable:
  ...
  require _param2 == addr(_param2)
  ...
  if currentWindow <= _param1:
      revert with 0, 'cannot claim for a future window'

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 antérieure.
  ...
  if stor5[_claimWindow][addr(_claimFor)]:
      revert with 0, 'Account already claimed the given window'

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

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.

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

C'est ainsi qu'un contrat transfère son propre ETH à une autre adresse (contrat ou détenue en 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.

  if not return_data.size:
      if not ext_call.success:
          require ext_code.size(stor2)
          call stor2.deposit() with:
             value unknown81e580d3[_param1] * _param3 / 100 * 10^6 wei

Les deux dernières lignes nous indiquent que Storage[2] est également un contrat que nous appelons. Si nous regardons la transaction du constructeur (opens in a new tab), nous voyons que ce contrat est 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 (opens in a new tab), un contrat d'ether enveloppé (WETH) dont le code source a été téléchargé sur Etherscan (opens in a new tab).

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

  ...
  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. 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 a 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 que nous rétro-ingénierons, et a reçu de l'ETH en retour.

A claim transaction

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.

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

Il semble donc s'agir d'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ération, soit (quand cela fonctionne) le décompilateur. Comme le montre la longueur de cet article, la rétro-ingénierie 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.

Découvrez d'autres de mes travaux ici (opens in a new tab).