Rétro-ingénierie d'un contrat
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.
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écalage | Code d'opération | Pile (après le code d'opération) |
|---|---|---|
| 0 | PUSH1 0x80 | 0x80 |
| 2 | PUSH1 0x40 | 0x40, 0x80 |
| 4 | MSTORE | Vide |
| 5 | PUSH1 0x04 | 0x04 |
| 7 | CALLDATASIZE | CALLDATASIZE 0x04 |
| 8 | LT | CALLDATASIZE<4 |
| 9 | PUSH2 0x005e | 0x5E CALLDATASIZE<4 |
| C | JUMPI | Vide |
Ce code fait deux choses :
- É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).
- 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.
Le gestionnaire à 0x5E (pour les données d'appel non-ABI)
| Décalage | Code d'opération |
|---|---|
| 5E | JUMPDEST |
| 5F | CALLDATASIZE |
| 60 | PUSH2 0x007c |
| 63 | JUMPI |
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écalage | Code d'opération | Pile (après le code d'opération) |
|---|---|---|
| 64 | CALLVALUE | fourni par l'appel. Appelé msg.value en Solidity |
| 65 | PUSH1 0x06 | 6 CALLVALUE |
| 67 | PUSH1 0x00 | 0 6 CALLVALUE |
| 69 | DUP3 | CALLVALUE 0 6 CALLVALUE |
| 6A | DUP3 | 6 CALLVALUE 0 6 CALLVALUE |
| 6B | SLOAD | Storage[6] CALLVALUE 0 6 CALLVALUE |
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.
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.
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écalage | Code d'opération | Pile |
|---|---|---|
| 6C | PUSH2 0x0075 | 0x75 Value* CALLVALUE 0 6 CALLVALUE |
| 6F | SWAP2 | CALLVALUE Value* 0x75 0 6 CALLVALUE |
| 70 | SWAP1 | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 71 | PUSH2 0x01a7 | 0x01A7 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 74 | JUMP |
Nous allons continuer à tracer ce code à la destination du saut.
| Décalage | Code d'opération | Pile |
|---|---|---|
| 1A7 | JUMPDEST | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1A8 | PUSH1 0x00 | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AA | DUP3 | CALLVALUE 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AB | NOT | 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
Le NOT est au niveau du bit (bitwise), il inverse donc la valeur de chaque bit dans la valeur d'appel.
| Décalage | Code d'opération | Pile |
|---|---|---|
| 1AC | DUP3 | Value* 2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AD | GT | Value*>2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AE | ISZERO | Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1AF | PUSH2 0x01df | 0x01DF Value*<=2^256-CALLVALUE-1 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1B2 | JUMPI |
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écalage | Code d'opération | Pile |
|---|---|---|
| 1DF | JUMPDEST | 0x00 Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E0 | POP | Value* CALLVALUE 0x75 0 6 CALLVALUE |
| 1E1 | ADD | Value*+CALLVALUE 0x75 0 6 CALLVALUE |
| 1E2 | SWAP1 | 0x75 Value*+CALLVALUE 0 6 CALLVALUE |
| 1E3 | JUMP |
Si nous sommes arrivés ici, obtenez Value* + CALLVALUE et sautez au décalage 0x75.
| Décalage | Code d'opération | Pile |
|---|---|---|
| 75 | JUMPDEST | Value*+CALLVALUE 0 6 CALLVALUE |
| 76 | SWAP1 | 0 Value*+CALLVALUE 6 CALLVALUE |
| 77 | SWAP2 | 6 Value*+CALLVALUE 0 CALLVALUE |
| 78 | SSTORE | 0 CALLVALUE |
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écalage | Code d'opération |
|---|---|
| 79 | POP |
| 7A | POP |
| 7B | STOP |
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.
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écalage | Code d'opération | Pile |
|---|---|---|
| 7C | JUMPDEST | |
| 7D | PUSH1 0x00 | 0x00 |
| 7F | PUSH2 0x009d | 0x9D 0x00 |
| 82 | PUSH1 0x03 | 0x03 0x9D 0x00 |
| 84 | SLOAD | Storage[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écalage | Code d'opération | Pile |
|---|---|---|
| 85 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xff....ff Storage[3] 0x9D 0x00 |
| 9A | AND | Storage[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écalage | Code d'opération | Pile |
|---|---|---|
| 9B | SWAP1 | 0x9D Storage[3]-en-adresse 0x00 |
| 9C | JUMP | Storage[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écalage | Code d'opération | Pile |
|---|---|---|
| 9D | JUMPDEST | Storage[3]-en-adresse 0x00 |
| 9E | SWAP1 | 0x00 Storage[3]-en-adresse |
| 9F | POP | Storage[3]-en-adresse |
| A0 | PUSH1 0x40 | 0x40 Storage[3]-en-adresse |
| A2 | MLOAD | Mem[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écalage | Code d'opération | Pile |
|---|---|---|
| A3 | CALLDATASIZE | CALLDATASIZE 0x80 Storage[3]-en-adresse |
| A4 | PUSH1 0x00 | 0x00 CALLDATASIZE 0x80 Storage[3]-en-adresse |
| A6 | DUP3 | 0x80 0x00 CALLDATASIZE 0x80 Storage[3]-en-adresse |
| A7 | CALLDATACOPY | 0x80 Storage[3]-en-adresse |
Copie toutes les données d'appel en mémoire, en commençant à 0x80.
| Décalage | Code d'opération | Pile |
|---|---|---|
| A8 | PUSH1 0x00 | 0x00 0x80 Storage[3]-en-adresse |
| AA | DUP1 | 0x00 0x00 0x80 Storage[3]-en-adresse |
| AB | CALLDATASIZE | CALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse |
| AC | DUP4 | 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse |
| AD | DUP6 | Storage[3]-en-adresse 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse |
| AE | GAS | GAS Storage[3]-en-adresse 0x80 CALLDATASIZE 0x00 0x00 0x80 Storage[3]-en-adresse |
| AF | DELEGATE_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écalage | Code d'opération | Pile |
|---|---|---|
| B0 | RETURNDATASIZE | RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| B1 | DUP1 | RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| B2 | PUSH1 0x00 | 0x00 RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| B4 | DUP5 | 0x80 0x00 RETURNDATASIZE RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| B5 | RETURNDATACOPY | RETURNDATASIZE (((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écalage | Code d'opération | Pile |
|---|---|---|
| B6 | DUP2 | (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| B7 | DUP1 | (((succès/échec de l'appel))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| B8 | ISZERO | (((l'appel a-t-il échoué))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| B9 | PUSH2 0x00c0 | 0xC0 (((l'appel a-t-il échoué))) (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| BC | JUMPI | (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| BD | DUP2 | RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| BE | DUP5 | 0x80 RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| BF | RETURN |
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écalage | Code d'opération | Pile |
|---|---|---|
| C0 | JUMPDEST | (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| C1 | DUP2 | RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| C2 | DUP5 | 0x80 RETURNDATASIZE (((succès/échec de l'appel))) RETURNDATASIZE (((succès/échec de l'appel))) 0x80 Storage[3]-en-adresse |
| C3 | REVERT |
Nous exécutons donc REVERT avec le même tampon que celui utilisé pour RETURN plus tôt : 0x80 - 0x80+RETURNDATASIZE
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écalage | Code d'opération | Pile |
|---|---|---|
| D | PUSH1 0x00 | 0x00 |
| F | CALLDATALOAD | (((Premier mot (256 bits) des données d'appel))) |
| 10 | PUSH1 0xe0 | 0xE0 (((Premier mot (256 bits) des données d'appel))) |
| 12 | SHR | (((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écalage | Code d'opération | Pile |
|---|---|---|
| 13 | DUP1 | (((les 32 premiers bits (4 octets) des données d'appel))) (((les 32 premiers bits (4 octets) des données d'appel))) |
| 14 | PUSH4 0x3cd8045e | 0x3CD8045E (((les 32 premiers bits (4 octets) des données d'appel))) (((les 32 premiers bits (4 octets) des données d'appel))) |
| 19 | GT | 0x3CD8045E>les-32-premiers-bits-des-donnees-d-appel (((les 32 premiers bits (4 octets) des données d'appel))) |
| 1A | PUSH2 0x0043 | 0x43 0x3CD8045E>les-32-premiers-bits-des-donnees-d-appel (((les 32 premiers bits (4 octets) des données d'appel))) |
| 1D | JUMPI | (((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éthode | Signature de méthode | Décalage vers lequel sauter |
|---|---|---|
| splitter() (opens in a new tab) | 0x3cd8045e | 0x0103 |
| ??? | 0x81e580d3 | 0x0138 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0158 |
| ??? | 0x1f135823 | 0x00C4 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x00ED |
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.
splitter()
| Décalage | Code d'opération | Pile |
|---|---|---|
| 103 | JUMPDEST | |
| 104 | CALLVALUE | CALLVALUE |
| 105 | DUP1 | CALLVALUE CALLVALUE |
| 106 | ISZERO | CALLVALUE==0 CALLVALUE |
| 107 | PUSH2 0x010f | 0x010F CALLVALUE==0 CALLVALUE |
| 10A | JUMPI | CALLVALUE |
| 10B | PUSH1 0x00 | 0x00 CALLVALUE |
| 10D | DUP1 | 0x00 0x00 CALLVALUE |
| 10E | REVERT |
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écalage | Code d'opération | Pile |
|---|---|---|
| 10F | JUMPDEST | |
| 110 | POP | |
| 111 | PUSH1 0x03 | 0x03 |
| 113 | SLOAD | (((Storage[3] alias le contrat pour lequel nous sommes un proxy))) |
| 114 | PUSH1 0x40 | 0x40 (((Storage[3] alias le contrat pour lequel nous sommes un proxy))) |
| 116 | MLOAD | 0x80 (((Storage[3] alias le contrat pour lequel nous sommes un proxy))) |
| 117 | PUSH20 0xffffffffffffffffffffffffffffffffffffffff | 0xFF...FF 0x80 (((Storage[3] alias le contrat pour lequel nous sommes un proxy))) |
| 12C | SWAP1 | 0x80 0xFF...FF (((Storage[3] alias le contrat pour lequel nous sommes un proxy))) |
| 12D | SWAP2 | (((Storage[3] alias le contrat pour lequel nous sommes un proxy))) 0xFF...FF 0x80 |
| 12E | AND | ProxyAddr 0x80 |
| 12F | DUP2 | 0x80 ProxyAddr 0x80 |
| 130 | MSTORE | 0x80 |
Et 0x80 contient maintenant l'adresse du proxy
| Décalage | Code d'opération | Pile |
|---|---|---|
| 131 | PUSH1 0x20 | 0x20 0x80 |
| 133 | ADD | 0xA0 |
| 134 | PUSH2 0x00e4 | 0xE4 0xA0 |
| 137 | JUMP | 0xA0 |
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écalage | Code d'opération | Pile |
|---|---|---|
| E4 | JUMPDEST | X |
| E5 | PUSH1 0x40 | 0x40 X |
| E7 | MLOAD | 0x80 X |
| E8 | DUP1 | 0x80 0x80 X |
| E9 | SWAP2 | X 0x80 0x80 |
| EA | SUB | X-0x80 0x80 |
| EB | SWAP1 | 0x80 X-0x80 |
| EC | RETURN |
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écalage | Code d'opération | Pile |
|---|---|---|
| 164 | JUMPDEST | |
| 165 | POP | |
| 166 | PUSH2 0x00da | 0xDA |
| 169 | PUSH1 0x01 | 0x01 0xDA |
| 16B | SLOAD | Storage[1] 0xDA |
| 16C | DUP2 | 0xDA Storage[1] 0xDA |
| 16D | JUMP | Storage[1] 0xDA |
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écalage | Code d'opération | Pile |
|---|---|---|
| DA | JUMPDEST | Y 0xDA |
| DB | PUSH1 0x40 | 0x40 Y 0xDA |
| DD | MLOAD | 0x80 Y 0xDA |
| DE | SWAP1 | Y 0x80 0xDA |
| DF | DUP2 | 0x80 Y 0x80 0xDA |
| E0 | MSTORE | 0x80 0xDA |
Écrire Y dans 0x80-0x9F.
| Décalage | Code d'opération | Pile |
|---|---|---|
| E1 | PUSH1 0x20 | 0x20 0x80 0xDA |
| E3 | ADD | 0xA0 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.
| Offset | Code d'opération | Pile |
|---|---|---|
| F9 | JUMPDEST | |
| FA | POP | |
| FB | PUSH2 0x00da | 0xDA |
| FE | PUSH1 0x00 | 0x00 0xDA |
| 100 | SLOAD | Storage[0] 0xDA |
| 101 | DUP2 | 0xDA Storage[0] 0xDA |
| 102 | JUMP | Storage[0] 0xDA |
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écalage | Code d'opération | Pile |
|---|---|---|
| 144 | JUMPDEST | |
| 145 | POP | |
| 146 | PUSH2 0x00da | 0xDA |
| 149 | PUSH2 0x0153 | 0x0153 0xDA |
| 14C | CALLDATASIZE | CALLDATASIZE 0x0153 0xDA |
| 14D | PUSH1 0x04 | 0x04 CALLDATASIZE 0x0153 0xDA |
| 14F | PUSH2 0x018f | 0x018F 0x04 CALLDATASIZE 0x0153 0xDA |
| 152 | JUMP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 18F | JUMPDEST | 0x04 CALLDATASIZE 0x0153 0xDA |
| 190 | PUSH1 0x00 | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 192 | PUSH1 0x20 | 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 194 | DUP3 | 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 195 | DUP5 | CALLDATASIZE 0x04 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 196 | SUB | CALLDATASIZE-4 0x20 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 197 | SLT | CALLDATASIZE-4<32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 198 | ISZERO | CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 199 | PUSH2 0x01a0 | 0x01A0 CALLDATASIZE-4>=32 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19C | JUMPI | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
Il semble que cette fonction prenne au moins 32 octets (un mot) de données d'appel.
| Décalage | Code d'opération | Pile |
|---|---|---|
| 19D | DUP1 | 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19E | DUP2 | 0x00 0x00 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 19F | REVERT |
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écalage | Code d'opération | Pile |
|---|---|---|
| 1A0 | JUMPDEST | 0x00 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A1 | POP | 0x04 CALLDATASIZE 0x0153 0xDA |
| 1A2 | CALLDATALOAD | calldataload(4) CALLDATASIZE 0x0153 0xDA |
calldataload(4) est le premier mot des données d'appel après la signature de la méthode
| Décalage | Code d'opération | Pile |
|---|---|---|
| 1A3 | SWAP2 | 0x0153 CALLDATASIZE calldataload(4) 0xDA |
| 1A4 | SWAP1 | CALLDATASIZE 0x0153 calldataload(4) 0xDA |
| 1A5 | POP | 0x0153 calldataload(4) 0xDA |
| 1A6 | JUMP | calldataload(4) 0xDA |
| 153 | JUMPDEST | calldataload(4) 0xDA |
| 154 | PUSH2 0x016e | 0x016E calldataload(4) 0xDA |
| 157 | JUMP | calldataload(4) 0xDA |
| 16E | JUMPDEST | calldataload(4) 0xDA |
| 16F | PUSH1 0x04 | 0x04 calldataload(4) 0xDA |
| 171 | DUP2 | calldataload(4) 0x04 calldataload(4) 0xDA |
| 172 | DUP2 | 0x04 calldataload(4) 0x04 calldataload(4) 0xDA |
| 173 | SLOAD | Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 174 | DUP2 | calldataload(4) Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 175 | LT | calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 176 | PUSH2 0x017e | 0x017EC calldataload(4)<Storage[4] calldataload(4) 0x04 calldataload(4) 0xDA |
| 179 | JUMPI | calldataload(4) 0x04 calldataload(4) 0xDA |
Si le premier mot n'est pas inférieur à Storage[4], la fonction échoue. Elle est annulée sans aucune valeur de retour :
| Décalage | Code d'opération | Pile |
|---|---|---|
| 17A | PUSH1 0x00 | 0x00 ... |
| 17C | DUP1 | 0x00 0x00 ... |
| 17D | REVERT |
Si le calldataload(4) est inférieur à Storage[4], nous obtenons ce code :
| Décalage | Code d'opération | Pile |
|---|---|---|
| 17E | JUMPDEST | calldataload(4) 0x04 calldataload(4) 0xDA |
| 17F | PUSH1 0x00 | 0x00 calldataload(4) 0x04 calldataload(4) 0xDA |
| 181 | SWAP2 | 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 182 | DUP3 | 0x00 0x04 calldataload(4) 0x00 calldataload(4) 0xDA |
| 183 | MSTORE | calldataload(4) 0x00 calldataload(4) 0xDA |
Et les emplacements mémoire 0x00-0x1F contiennent maintenant la donnée 0x04 (0x00-0x1E sont tous des zéros, 0x1F vaut quatre)
| Décalage | Code d'opération | Pile |
|---|---|---|
| 184 | PUSH1 0x20 | 0x20 calldataload(4) 0x00 calldataload(4) 0xDA |
| 186 | SWAP1 | calldataload(4) 0x20 0x00 calldataload(4) 0xDA |
| 187 | SWAP2 | 0x00 0x20 calldataload(4) calldataload(4) 0xDA |
| 188 | SHA3 | (((SHA3 of 0x00-0x1F))) calldataload(4) calldataload(4) 0xDA |
| 189 | ADD | (((SHA3 of 0x00-0x1F)))+calldataload(4) calldataload(4) 0xDA |
| 18A | SLOAD | Storage[(((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écalage | Code d'opération | Pile |
|---|---|---|
| 18B | SWAP1 | calldataload(4) Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18C | POP | Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18D | DUP2 | 0xDA Storage[(((SHA3 of 0x00-0x1F))) + calldataload(4)] 0xDA |
| 18E | JUMP | Storage[(((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écalage | Code d'opération | Pile |
|---|---|---|
| D0 | JUMPDEST | |
| D1 | POP | |
| D2 | PUSH2 0x00da | 0xDA |
| D5 | PUSH1 0x06 | 0x06 0xDA |
| D7 | SLOAD | Valeur* 0xDA |
| D8 | DUP2 | 0xDA Valeur* 0xDA |
| D9 | JUMP | Valeur* 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éthode | Signification |
|---|---|
| Transfert | Accepter 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] |
| 0x81e580d3 | Renvoyer la valeur d'une table de correspondance, à condition que le paramètre soit inférieur à Storage[4] |
| 0x1f135823 | Renvoyer 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éé.
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 :
| Méthode | Signature de la méthode | Décalage pour sauter à |
|---|---|---|
| scaleAmountByPercentage(uint256,uint256) (opens in a new tab) | 0x8ffb5c97 | 0x0135 |
| isClaimed(uint256,address) (opens in a new tab) | 0xd2ef0795 | 0x0151 |
| claim(uint256,address,uint256,bytes32[]) (opens in a new tab) | 0x2e7ba6ef | 0x00F4 |
| incrementWindow() (opens in a new tab) | 0x338b1d31 | 0x0110 |
| ??? | 0x3f26479e | 0x0118 |
| ??? | 0x1e7df9d3 | 0x00C3 |
| currentWindow() (opens in a new tab) | 0xba0bafb4 | 0x0148 |
| merkleRoot() (opens in a new tab) | 0x2eb4a7ab | 0x0107 |
| ??? | 0x81e580d3 | 0x0122 |
| ??? | 0x1f135823 | 0x00D8 |
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 unuint256, est en fait une adresse_param1est la fenêtre réclamée, qui doit êtrecurrentWindowou 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.
...
idx = 0
s = 0
while idx < _param4.length:
...
if s + sha3(mem[(32 * _param4.length) + 328 len mem[(32 * _param4.length) + 296]]) > mem[(32 * idx) + 296]:
mem[mem[64] + 32] = mem[(32 * idx) + 296]
...
s = sha3(mem[_62 + 32 len mem[_62]])
continue
...
s = sha3(mem[_66 + 32 len mem[_66]])
continue
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
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.
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.
def unknown1e7df9d3(uint256 _param1, uint256 _param2, array _param3) payable:
...
idx = 0
s = 0
while idx < _param3.length:
if idx >= mem[96]:
revert with 0, 50
_55 = mem[(32 * idx) + 128]
if s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]]) > mem[(32 * idx) + 128]:
...
s = sha3(mem[_58 + 32 len mem[_58]])
continue
mem[mem[64] + 32] = s + sha3(mem[(32 * _param3.length) + 160 len mem[(32 * _param3.length) + 128]])
...
if unknown2eb4a7ab != s:
revert with 0, 'Invalid proof'
...
call addr(_param1) with:
value s wei
gas 30000 wei
if not return_data.size:
if not ext_call.success:
require ext_code.size(stor2)
call stor2.deposit() with:
value s wei
gas gas_remaining wei
...
log 0xdbd5389f: addr(_param1), s, bool(ext_call.success)
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.
idx = 0
s = 0
while idx < currentWindow:
...
if stor5[mem[0]]:
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s
continue
...
stor5[idx][addr(_param1)] = 1
if idx >= unknown81e580d3.length:
revert with 0, 50
mem[0] = 4
if unknown81e580d3[idx] and _param2 > -1 / unknown81e580d3[idx]:
revert with 0, 17
if s > !(unknown81e580d3[idx] * _param2 / 100 * 10^6):
revert with 0, 17
if idx == -1:
revert with 0, 17
idx = idx + 1
s = s + (unknown81e580d3[idx] * _param2 / 100 * 10^6)
continue
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.



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



