Sécurité des contrats intelligents

Bannière Amazon du livre Maîtriser Ethereum

La sécurité est l’une des considérations les plus importantes lors de la rédaction de contrats intelligents. Dans le domaine de la programmation de contrats intelligents, les erreurs sont coûteuses et facilement exploitables. Dans ce chapitre, nous examinerons les meilleures pratiques de sécurité et les modèles de conception, ainsi que les «anti-modèles de sécurité», qui sont des pratiques et des modèles qui peuvent introduire des vulnérabilités dans nos contrats intelligents.

Comme avec d’autres programmes, un contrat intelligent exécutera exactement ce qui est écrit, ce qui n’est pas toujours ce que le programmeur avait prévu. De plus, tous les contrats intelligents sont publics et tout utilisateur peut interagir avec eux simplement en créant une transaction. Toute vulnérabilité peut être exploitée et les pertes sont presque toujours impossibles à récupérer. Il est donc essentiel de suivre les meilleures pratiques et d’utiliser des modèles de conception éprouvés.

Meilleures pratiques de sécurité

La programmation défensive est un style de programmation particulièrement bien adapté aux contrats intelligents. Il met l’accent sur les éléments suivants, qui sont tous des pratiques exemplaires :

Minimalisme/simplicité

La complexité est l’ennemie de la sécurité. Plus le code est simple et moins il en fait, moins il y a de chances qu’un bogue ou un effet imprévu se produise. Lorsqu’ils s’engagent pour la première fois dans la programmation de contrats intelligents, les développeurs sont souvent tentés d’essayer d’écrire beaucoup de code. Au lieu de cela, vous devriez parcourir votre code de contrat intelligent et essayer de trouver des moyens d’en faire moins, avec moins de lignes de code, moins de complexité et moins de "fonctionnalités". Si quelqu’un vous dit que son projet a produit "des milliers de lignes de code" pour ses contrats intelligents, vous devez vous interroger sur la sécurité de ce projet. Plus simple est plus sûr.

Réutilisation du code

Essayez de ne pas réinventer la roue. S’il existe déjà une bibliothèque ou un contrat qui fait la plupart de ce dont vous avez besoin, réutilisez-le. Dans votre propre code, suivez le principe DRY (Don’t Repeat Yourself) : ne vous répétez pas. Si vous voyez un extrait de code répété plus d’une fois, demandez-vous s’il pourrait être écrit en tant que fonction ou bibliothèque et réutilisé. Le code qui a été largement utilisé et testé est probablement plus sécurisé que tout nouveau code que vous écrivez. Méfiez-vous du syndrome "Pas inventé ici", où vous êtes tenté d'"améliorer" une fonctionnalité ou un composant en le construisant à partir de zéro. Le risque de sécurité est souvent supérieur à la valeur d’amélioration.

Qualité du code

Le code de contrat intelligent est impitoyable. Chaque bogue peut entraîner une perte monétaire. Vous ne devez pas traiter la programmation de contrats intelligents de la même manière que la programmation à usage général. Écrire des DApps dans Solidity n’est pas comme créer un widget Web en JavaScript. Vous devez plutôt appliquer des méthodologies d’ingénierie et de développement de logiciels rigoureuses, comme vous le feriez dans l’ingénierie aérospatiale ou dans toute discipline similairement impitoyable. Une fois que vous avez "lancé" votre code, vous ne pouvez pas faire grand-chose pour résoudre les problèmes.

Lisibilité/auditabilité

Votre code doit être clair et facile à comprendre. Plus c’est facile à lire, plus c’est facile à auditer. Les contrats intelligents sont publics, car tout le monde peut lire le code intermédiaire et tout le monde peut le désosser. Par conséquent, il est avantageux de développer votre travail en public, en utilisant des méthodologies collaboratives et à source libre, pour tirer parti de la sagesse collective de la communauté des développeurs et bénéficier du plus grand dénominateur commun du développement à source libre. Vous devez écrire un code bien documenté et facile à lire, en suivant les conventions de style et de nommage qui font partie de la communauté Ethereum.

Couverture de test

Testez tout ce que vous pouvez. Les contrats intelligents s’exécutent dans un environnement d’exécution public, où n’importe qui peut les exécuter avec la contribution de son choix. Vous ne devez jamais supposer que l’entrée, telle que les arguments de fonction, est bien formée, correctement délimitée ou a un objectif bénin. Testez tous les arguments pour vous assurer qu’ils se situent dans les plages attendues et qu’ils sont correctement formatés avant d’autoriser la poursuite de l’exécution de votre code.

Risques de sécurité et anti-modèles

En tant que programmeur de contrats intelligents, vous devez être familiarisé avec les risques de sécurité les plus courants, afin de pouvoir détecter et éviter les modèles de programmation qui exposent vos contrats à ces risques. Dans les sections suivantes, nous examinerons différents risques de sécurité, des exemples de la façon dont les vulnérabilités peuvent survenir et des contre-mesures ou des solutions préventives qui peuvent être utilisées pour y faire face.

Réentrance

L’une des caractéristiques des contrats intelligents Ethereum est leur capacité à appeler et à utiliser le code d’autres contrats externes. Les contrats gèrent également généralement l’ether et, en tant que tels, envoient souvent de l’ether à diverses adresses d’utilisateurs externes. Ces opérations nécessitent que les contrats soumettent des appels externes. Ces appels externes peuvent être détournés par des attaquants, qui peuvent forcer les contrats à exécuter du code supplémentaire (via une fonction de secours ), y compris des rappels vers eux-mêmes. Des attaques de ce type ont été utilisées dans le tristement célèbre piratage DAO .

Pour en savoir plus sur les attaques par réentrance , voir l’article de blog de Gus Guimareas sur le sujet et les meilleures pratiques pour les contrats intelligents Ethereum.

La vulnérabilité

Ce type d’attaque peut se produire lorsqu’un contrat envoie de l’ether à une adresse inconnue. Un attaquant peut soigneusement construire un contrat à une adresse externe qui contient du code malveillant dans la fonction de secours. Ainsi, lorsqu’un contrat envoie de l’ether à cette adresse, il invoquera le code malveillant. Généralement , le code malveillant exécute une fonction sur le contrat vulnérable, effectuant des opérations non prévues par le développeur. Le terme « réentrance » vient du fait que le contrat externe malveillant appelle une fonction sur le contrat vulnérable et que le chemin d’exécution du code le « réintègre ». Pour clarifier cela, considérons le simple contrat vulnérable dans EtherStore.sol , qui agit comme un coffre-fort Ethereum qui permet aux déposants de retirer seulement 1 ether par semaine.

Example 1. EtherStore.sol
contract EtherStore {

    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function depositFunds() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(balances[msg.sender] >= _weiToWithdraw);
        // limite le retrait
        require(_weiToWithdraw <= withdrawalLimit);
        // limite le temps accordé pour se rétracter
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        require(msg.sender.call.value(_weiToWithdraw)());
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
    }
 }

Ce contrat a deux fonctions publiques, depositFunds et removeFunds. La fonction de dépôt de fonds incrémente simplement le solde de l’expéditeur. La fonction de retrait de fonds permet à l’expéditeur de spécifier le montant de wei à retirer. Cette fonction est destinée à réussir uniquement si le montant demandé à retirer est inférieur à 1 ether et qu’aucun retrait n’a eu lieu la semaine dernière.

La vulnérabilité se trouve à la ligne 17, où le contrat envoie à l’utilisateur la quantité d’ether demandée. Considérez un attaquant qui a créé le contrat dans Attack.sol .

Example 2. Attack.sol
import "EtherStore.sol";

contract Attack {
  EtherStore public etherStore;

  // initialise la variable etherStore avec l'adresse du contrat
  constructor(address _etherStoreAddress) {
      etherStore = EtherStore(_etherStoreAddress);
  }

  function attackEtherStore() external payable {
      // attaque à l'ether le plus proche
      require(msg.value >= 1 ether);
      // envoie eth à la fonction depositFunds()
      etherStore.depositFunds.value(1 ether)();
      // début de magie
      etherStore.withdrawFunds(1 ether);
  }

  function collectEther() public {
      msg.sender.transfer(this.balance);
  }

  // fonction de secours - où la magie opère
  function () payable {
      if (etherStore.balance > 1 ether) {
          etherStore.withdrawFunds(1 ether);
      }
  }
}

Comment l’exploit peut-il se produire ? Tout d’abord, l’attaquant créerait le contrat malveillant (disons à l’adresse 0x0…​123) avec l' adresse de contrat de l' EtherStore comme seul paramètre constructeur. Cela initialiserait et pointerait la variable publique etherStore vers le contrat à attaquer.

L’attaquant appellerait alors la fonction attackEtherStore , avec une certaine quantité d’ether supérieure ou égale à 1 - supposons 1 ether pour le moment. Dans cet exemple, nous supposerons également qu’un certain nombre d’autres utilisateurs ont déposé de l’ether dans ce contrat, de sorte que son solde actuel est de 10 ether. La suite sera alors ainsi :

  1. Attack.sol , ligne 15 : La fonction DepositFunds du contrat EtherStore sera appelée avec un msg.value de 1 ether (et beaucoup de gaz). L’expéditeur (msg.sender) sera le contrat malveillant ( 0x0…​​123 ). Ainsi , balances[0x0…​123] = 1 ether.

  2. Attack.sol , ligne 17 : Le contrat malveillant va alors appeler la fonction withdrawFunds du contrat EtherStore avec un paramètre de 1 ether . Cela satisfera à toutes les exigences (lignes 12 à 16 du contrat EtherStore ) car aucun retrait précédent n’a été effectué.

  3. EtherStore.sol , ligne 17 : Le contrat renverra 1 ether au contrat malveillant.

  4. Attack.sol , ligne 25 : Le paiement au contrat malveillant exécutera alors la fonction de secours.

  5. Attack.sol , ligne 26 : Le solde total du contrat EtherStore était de 10 ether et est maintenant de 9 ether , donc cette instruction if passe.

  6. Attack.sol , ligne 27 : La fonction de secours appelle de l' EtherStore la fonction withdrawFunds à nouveau et « réintègre » le contrat EtherStore .

  7. EtherStore.sol , ligne 11 : Dans ce deuxième appel à withdrawFunds , le solde du contrat attaquant est toujours de 1 ether car la ligne 18 n’a pas encore été exécutée. Ainsi, nous avons toujours balances[0x0…​123] = 1 ether . C’est également le cas pour la variable lastWithdrawTime . Encore une fois , nous passons toutes les exigences .

  8. EtherStore.sol , ligne 17 : Le contrat attaquant retire un autre 1 ether .

  9. Répétez les étapes 4 à 8 jusqu’à ce qu’il ne soit plus le cas que EtherStore.balance > 1 , comme dicté par la ligne 26 dans Attack.sol .

  10. Attack.sol , ligne 26 : Une fois qu’il reste 1 (ou moins) d’ether dans le contrat EtherStore , cette instruction if échouera. Cela permettra alors d’exécuter les lignes 18 et 19 du contrat EtherStore (pour chaque appel à la fonction withdrawFunds).

  11. EtherStore.sol , lignes 18 et 19 : Les mappages balances et lastWithdrawTime seront définis et l’exécution se terminera.

Le résultat final est que l’attaquant a retiré tous les ethers sauf 1 du contrat EtherStore en une seule transaction.

Techniques préventives

Il existe un certain nombre de techniques courantes qui permettent d’éviter les vulnérabilités potentielles de réentrance dans les contrats intelligents. La première consiste à (dans la mesure du possible) utiliser la fonction de transfert intégrée lors de l’envoi d’ether à des contrats externes. La fonction de transfert n’envoie que 2300 gaz avec l’appel externe, ce qui n’est pas suffisant pour que l’adresse/le contrat de destination appelle un autre contrat (c’est-à-dire qu’il ressaisisse le contrat d’envoi).

La deuxième technique consiste à s’assurer que toute la logique qui modifie les variables d’état se produit avant que l’ether ne soit envoyé hors du contrat (ou de tout appel externe). Dans l' exemple EtherStore, les lignes 18 et 19 de EtherStore.sol doivent être placées avant la ligne 17. Il est recommandé que tout code effectuant des appels externes à des adresses inconnues soit la dernière opération d’une fonction localisée ou d’un morceau de code exécuté. C’est ce qu’on appelle le modèle vérifications-effets-interactions .

Une troisième technique consiste à introduire un mutex, c’est-à-dire à ajouter une variable d’état qui verrouille le contrat pendant l’exécution du code, empêchant les appels réentrants .

L’application de toutes ces techniques (l’utilisation des trois n’est pas nécessaire, mais nous le faisons à des fins de démonstration) à EtherStore.sol donne le contrat sans réentrance :

contract EtherStore {

    // initialise le mutex
    bool reEntrancyMutex = false;
    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public lastWithdrawTime;
    mapping(address => uint256) public balances;

    function depositFunds() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(!reEntrancyMutex);
        require(balances[msg.sender] >= _weiToWithdraw);
        // limite le retrait
        require(_weiToWithdraw <= withdrawalLimit);
        // limite le temps accordé pour se rétracter
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        balances[msg.sender] -= _weiToWithdraw;
        lastWithdrawTime[msg.sender] = now;
        // définit le mutex de réentrance avant l'appel externe
        reEntrancyMutex = true;
        msg.sender.transfer(_weiToWithdraw);
        // libère le mutex après l'appel externe
        reEntrancyMutex = false;
    }
 }

Exemple concret : le DAO

L’attaque DAO (Decentralized Autonomous Organization) a été l’un des principaux piratages survenus au début du développement d’Ethereum. À l’époque, le contrat détenait plus de 150 millions de dollars. La réentrance a joué un rôle majeur dans l’attaque, qui a finalement conduit à l’embranchement divergent (hard fork) qui a créé Ethereum Classic (ETC). Pour une bonne analyse de l’exploit DAO, voir http://bit.ly/2EQaLCI . Plus d’informations sur l’historique des embranchements d’Ethereum, la chronologie du piratage DAO et la naissance d’ETC dans un embranchement divergent peuvent être trouvées dans [ethereum_standards].

Dépassement et soupassement arithmétique

La machine virtuelle Ethereum spécifie des types de données de taille fixe pour les entiers. Cela signifie qu’une variable entière ne peut représenter qu’une certaine plage de nombres. Un uint8 , par exemple, ne peut stocker que des nombres dans la plage [0,255]. Essayer de stocker 256 dans un uint8 donnera 0 . Si l’on n’y prend pas garde, les variables de Solidity peuvent être exploitées si la saisie de l’utilisateur n’est pas cochée et si des calculs sont effectués qui aboutissent à des nombres qui se situent en dehors de la plage du type de données qui les stocke.

La vulnérabilité

Un dépassement/soupassement se produit lorsqu’une opération effectuée nécessite une variable de taille fixe pour stocker un nombre (ou un élément de données) qui est en dehors de la plage du type de données de la variable.

Par exemple, soustraire 1 d’une variable uint8 (entier non signé de 8 bits, c’est-à-dire non négatif) dont la valeur est 0 donnera le nombre 255 . Il s’agit d’un soupassement . Nous avons attribué un nombre en dessous de la plage de uint8 , de sorte que le résultat est une boucle et donne le plus grand nombre qu’un uint8 puisse stocker. De même, ajouter 2^8=256 à un uint8 laissera la variable inchangée, car nous avons enroulé autour de toute la longueur du uint . Deux analogies simples de ce comportement sont les odomètres dans les voitures, qui mesurent la distance parcourue (ils se réinitialisent à 000000, après que le plus grand nombre, c’est-à-dire 999999, est dépassé) et les fonctions mathématiques périodiques (l’ajout de 2 π à l’argument de sin laisse la valeur inchangée ).

L’ajout de nombres supérieurs à la plage du type de données est appelé un dépassement . Pour plus de clarté, ajouter 257 à un uint8 qui a actuellement une valeur de 0 se traduira par le nombre 1 . Il est parfois instructif de considérer les variables de taille fixe comme étant cycliques, où nous recommençons à partir de zéro si nous ajoutons des nombres au-dessus du plus grand nombre stocké possible, et commençons à compter à partir du plus grand nombre si nous soustrayons de zéro. Dans le cas des types int signés , qui peuvent représenter des nombres négatifs, nous recommençons une fois que nous atteignons la plus grande valeur négative; par exemple, si nous essayons de soustraire 1 à un int8 dont la valeur est -128 , nous obtiendrons 127 .

Ces types de pièges numériques permettent aux attaquants de mal utiliser le code et de créer des flux logiques inattendus. Par exemple, considérez le contrat TimeLock dans TimeLock.sol.

Example 3. TimeLock.sol
contract TimeLock {

    mapping(address => uint) public balances;
    mapping(address => uint) public lockTime;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        lockTime[msg.sender] = now + 1 weeks;
    }

    function increaseLockTime(uint _secondsToIncrease) public {
        lockTime[msg.sender] += _secondsToIncrease;
    }

    function withdraw() public {
        require(balances[msg.sender] > 0);
        require(now > lockTime[msg.sender]);
        balances[msg.sender] = 0;
        msg.sender.transfer(balance);
    }
}

Ce contrat est conçu pour agir comme un coffre-fort temporel : les utilisateurs peuvent déposer de l’ether dans le contrat et il y sera verrouillé pendant au moins une semaine. L’utilisateur peut prolonger le temps d’attente à plus d’une semaine s’il le souhaite, mais une fois déposé, l’utilisateur peut être sûr que son ether est verrouillé en toute sécurité pendant au moins une semaine, du moins c’est ce que prévoit ce contrat.

Dans le cas où un utilisateur est obligé de remettre sa clé privée, un contrat comme celui-ci peut être utile pour s’assurer que son ether est introuvable pendant une courte période. Mais si un utilisateur avait verrouillé 100 ether dans ce contrat et remis ses clés à un attaquant, l’attaquant pourrait utiliser un dépassement pour recevoir l’ether, quel que soit le lockTime.

L’attaquant pourrait déterminer le lockTime actuel pour l’adresse pour laquelle il détient maintenant la clé (c’est une variable publique). Appelons le userLockTime . Ils pourraient alors appeler la fonction increaseLockTime et passer en argument le nombre 2^256 - userLockTime . Ce nombre serait ajouté à l' userLockTime actuel et provoquerait un dépassement, réinitialisant lockTime[msg.sender] à 0. L’attaquant pourrait alors simplement appeler la fonction withdraw pour obtenir sa récompense.

ALERTE DIVULGATION: Si vous n’avez pas encore fait les défis Ethernaut, cela donne une solution à l’un des niveaux .

Example 4. Underflow vulnerability example from Ethernaut challenge
pragma solidity ^0.4.18;

contract Token {

  mapping(address => uint) balances;
  uint public totalSupply;

  function Token(uint _initialSupply) {
    balances[msg.sender] = totalSupply = _initialSupply;
  }

  function transfer(address _to, uint _value) public returns (bool) {
    require(balances[msg.sender] - _value >= 0);
    balances[msg.sender] -= _value;
    balances[_to] += _value;
    return true;
  }

  function balanceOf(address _owner) public constant returns (uint balance) {
    return balances[_owner];
  }
}

Il s’agit d’un simple contrat de jeton qui utilise une fonction de transfer , permettant aux participants de déplacer leurs jetons. Pouvez-vous voir l’erreur dans ce contrat ?

La faille vient de la fonction transfer. L’instruction require de la ligne 13 peut être contournée à l’aide d’un soupassement. Considérez un utilisateur avec un solde nul. Il pourrait appeler la fonction transfer avec n’importe quelle _value différente de zéro et passer l’instruction require à la ligne 13. En effet, balances[msg.sender] est égal à 0 (et un uint256 ), donc la soustraction de tout montant positif (à l’exception de 2^256 ) entraînera un nombre positif, comme décrit précédemment. Ceci est également vrai pour la ligne 14, où le solde sera crédité d’un nombre positif. Ainsi, dans cet exemple, un attaquant peut obtenir des jetons gratuits en raison d’une vulnérabilité de soupassement.

Techniques préventives

La technique conventionnelle actuelle pour se prémunir contre les vulnérabilités de soupassement/dépassement consiste à utiliser ou à créer des bibliothèques mathématiques qui remplacent les opérateurs mathématiques standard d' addition, de soustraction et de multiplication (la division est exclue car elle ne provoque pas de soupassement/dépassement et l’EVM revient à la division par 0 ).

OpenZeppelin a fait un excellent travail de création et d’audit de bibliothèques sécurisées pour la communauté Ethereum. En particulier, la bibliothèque SafeMath peut être utilisée pour éviter les vulnérabilités de soupassement/dépassement.

Pour montrer comment ces bibliothèques sont utilisées dans Solidity, corrigeons le contrat TimeLock à l’aide de la bibliothèque SafeMath. La version sans dépassement du contrat est :

library SafeMath {

  function mul(uint256 a, uint256 b) internal pure returns (uint256) {
    if (a == 0) {
      return 0;
    }
    uint256 c = a * b;
    assert(c / a == b);
    return c;
  }

  function div(uint256 a, uint256 b) internal pure returns (uint256) {
    // assert( b > 0); // Solidity relance automatiquement lors de la division par 0
    uint256 c = a / b;
    // assert( a == b * c + a % b); // Cela vaut dans tous les cas
    return c;
  }

  function sub(uint256 a, uint256 b) internal pure returns (uint256) {
    assert(b <= a);
    return a - b;
  }

  function add(uint256 a, uint256 b) internal pure returns (uint256) {
    uint256 c = a + b;
    assert(c >= a);
    return c;
  }
}

contract TimeLock {
    using SafeMath for uint; // utiliser la bibliothèque pour le type uint
    mapping(address => uint256) public balances;
    mapping(address => uint256) public lockTime;

    function deposit() external payable {
        balances[msg.sender] = balances[msg.sender].add(msg.value);
        lockTime[msg.sender] = now.add(1 weeks);
    }

    function increaseLockTime(uint256 _secondsToIncrease) public {
        lockTime[msg.sender] = lockTime[msg.sender].add(_secondsToIncrease);
    }

    function withdraw() public {
        require(balances[msg.sender] > 0);
        require(now > lockTime[msg.sender]);
        balances[msg.sender] = 0;
        msg.sender.transfer(balance);
    }
}

Notez que toutes les opérations mathématiques standard ont été remplacées par celles définies dans la bibliothèque SafeMath. Le contrat TimeLock n’effectue plus aucune opération capable de soupassement/dépassement.

Exemples concrets : PoWHC et dépassement de transfert par lots (CVE-2018–10299)

Proof of Weak Hands Coin ( PoWHC ), conçu à l’origine comme une sorte de blague, était un stratagème de Ponzi écrit par un collectif Internet. Malheureusement , il semble que l’auteur ou les auteurs du contrat n’avaient pas vu de soupassement/dépassement auparavant, et par conséquent 866 ethers ont été libérés de son contrat. Eric Banisadr donne un bon aperçu de la façon dont le dépassement s’est produit (ce qui n’est pas trop différent du défi Ethernaut décrit précédemment) dans son article de blog sur l’événement.

Un autre exemple provient de l’implémentation d’une fonction batchTransfer() dans un groupe de contrats de jetons ERC20. L’implémentation contenait une vulnérabilité de dépassement; vous pouvez lire les détails dans le compte rendu de PeckShield.

Ether inattendu

En règle générale, lorsque de l’ether est envoyé à un contrat, il doit exécuter soit la fonction de secours, soit une autre fonction définie dans le contrat. Il y a deux exceptions à cela, où l’ether peut exister dans un contrat sans avoir exécuté de code. Les contrats qui reposent sur l’exécution de code pour tout l’ether qui leur est envoyé peuvent être vulnérables aux attaques où l’ether est envoyé de force.

La vulnérabilité

Une technique de programmation défensive courante qui est utile pour appliquer des transitions d’état correctes ou valider des opérations est la vérification invariante . Cette technique consiste à définir un ensemble d’invariants (métriques ou paramètres qui ne doivent pas changer) et à vérifier qu’ils restent inchangés après une (ou plusieurs) opération(s). C’est généralement une bonne conception, à condition que les invariants vérifiés soient en fait des invariants. Un exemple d’invariant est totalSupply d’une émission fixe de Jeton ERC20 . Comme aucune fonction ne doit modifier cet invariant, on pourrait ajouter une vérification à la fonction transfer qui s’assure que le totalSupply reste inchangé, pour garantir que la fonction fonctionne comme prévu.

En particulier, il existe un invariant apparent qu’il peut être tentant d’utiliser mais qui peut en fait être manipulé par des utilisateurs externes (quelles que soient les règles mises en place dans le contrat intelligent). Il s’agit de l’ether actuel stocké dans le contrat. Souvent, lorsque les développeurs apprennent Solidity pour la première fois , ils ont l’idée fausse qu’un contrat ne peut accepter ou obtenir de l’ether que via des fonctions payantes. Cette idée fausse peut conduire à des contrats qui contiennent de fausses hypothèses sur l’équilibre de l’ether, ce qui peut entraîner une série de vulnérabilités. La preuve irréfutable de cette vulnérabilité est l’utilisation (incorrecte) de this.balance.

Il existe deux manières d’envoyer (de force) de l’ether à un contrat sans utiliser de fonction payante ni exécuter de code sur le contrat :

Autodestruction/suicide

Tout contrat est capable d' implémenter la fonction selfdestruct , qui supprime tout le code intermédiaire de l’adresse du contrat et envoie tout l’ether qui y est stocké à l’adresse spécifiée par le paramètre. Si cette adresse spécifiée est également un contrat, aucune fonction (y compris la fonction de secours) n’est appelée. Par conséquent, la fonction selfdestruct peut être utilisée pour envoyer de force de l’ether à n’importe quel contrat, quel que soit le code pouvant exister dans le contrat, même les contrats sans fonctions payantes. Cela signifie que tout attaquant peut créer un contrat avec une fonction selfdestruct , lui envoyer de l’ether, appeler selfdestruct(target) et forcer l’ether à être envoyé à un contrat target . Martin Swende a un excellent article de blog décrivant certaines bizarreries de l’opcode d’autodestruction (Quirk # 2) ainsi qu’un compte rendu de la façon dont les nœuds clients vérifiaient des invariants incorrects, ce qui aurait pu conduire à un crash plutôt catastrophique du réseau Ethereum.

Ether pré-envoyé

Une autre façon d’intégrer de l’ether dans un contrat consiste à précharger l’adresse du contrat avec de l’ether. Les adresses de contrat sont déterministes - en fait, l’adresse est calculée à partir du hachage Keccak-256 (généralement synonyme de SHA-3) de l’adresse créant le contrat et du nonce de transaction qui crée le contrat. Plus précisément, il se présente sous la forme address = sha3(rlp.encode([ account_address,transaction_nonce ])) (voir la discussion d’Adrian Manning sur "Keyless Ether" pour quelques cas d’utilisation amusants). Cela signifie que n’importe qui peut calculer quelle sera l’adresse d’un contrat avant sa création et envoyer de l’ether à cette adresse. Lorsque le contrat est créé, il aura un solde d’ether non nul.

Explorons quelques pièges qui peuvent survenir compte tenu de ces connaissances. Considérez le contrat trop simple dans EtherGame.sol.

Example 5. EtherGame.sol
contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;

    mapping(address => uint) redeemableEther;
    // Les utilisateurs paient 0,5 ether. À des étapes spécifiques, créditez leurs comptes.
    function play() external payable {
        require(msg.value == 0.5 ether); // chaque jeu est 0.5 ether
        uint currentBalance = this.balance + msg.value;
        // s'assure qu'il n'y a plus de joueurs après la fin du jeu
        require(currentBalance <= finalMileStone);
        // si à un jalon, créditer le compte du joueur
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }
        return;
    }

    function claimReward() public {
        // s'assure que le jeu est terminé
        require(this.balance == finalMileStone);
        // s'assure qu'il y a une récompense à donner
        require(redeemableEther[msg.sender] > 0);
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }

Ce contrat représente un jeu simple (qui impliquerait naturellement des conditions de course) où les joueurs envoient 0,5 ether au contrat dans l’espoir d’être le joueur qui atteint l’un des trois jalons en premier. Les jalons sont libellés en ether. Le premier à atteindre le jalon peut réclamer une partie de l’ether à la fin de la partie. Le jeu se termine lorsque le dernier jalon (10 ether) est atteint; les utilisateurs peuvent ensuite réclamer leurs récompenses.

Les problèmes avec le contrat EtherGame proviennent de la mauvaise utilisation de this.balance dans les deux lignes 14 (et par association 16) et 32. Un attaquant malicieux pourrait envoyer de force une petite quantité d’ether, disons 0,1 ether, via la fonction selfdestruct ( discuté plus tôt) pour empêcher tout futur joueur d’atteindre un jalon. this.balance ne sera jamais un multiple de 0,5 ether grâce à cette contribution de 0,1 ether, car tous les joueurs légitimes ne peuvent envoyer que des incréments de 0,5 ether. Cela empêche toutes les conditions if des lignes 18, 21 et 24 d’être vraies.

Pire encore, un attaquant vengeur qui a raté un jalon pourrait envoyer de force 10 ethers (ou une quantité équivalente d’ethers qui pousse le solde du contrat au-dessus du finalMileStone ), ce qui verrouillerait toutes les récompenses dans le contrat pour toujours. En effet, la fonction claimReward sera toujours rétablie, en raison de l’exigence à la ligne 32 (c’est-à-dire parce que this.balance est supérieur à finalMileStone ).

Techniques préventives

Ce type de vulnérabilité provient généralement d’une mauvaise utilisation de this.balance. La logique contractuelle, dans la mesure du possible, doit éviter de dépendre des valeurs exactes du solde du contrat, car elle peut être artificiellement manipulée. Si vous appliquez une logique basée sur this.balance , vous devez faire face à des soldes inattendus.

Si des valeurs exactes d’ether déposé sont requises, une variable auto-définie doit être utilisée qui est incrémentée dans les fonctions payables, pour suivre en toute sécurité l’ether déposé. Cette variable ne sera pas influencée par l’ether forcé envoyé via un appel selfdestruct.

contract EtherGame {

    uint public payoutMileStone1 = 3 ether;
    uint public mileStone1Reward = 2 ether;
    uint public payoutMileStone2 = 5 ether;
    uint public mileStone2Reward = 3 ether;
    uint public finalMileStone = 10 ether;
    uint public finalReward = 5 ether;
    uint public depositedWei;

    mapping (address => uint) redeemableEther;

    function play() external payable {
        require(msg.value == 0.5 ether);
        uint currentBalance = depositedWei + msg.value;
        // s'assure qu'il n'y a plus de joueurs après la fin du jeu
        require(currentBalance <= finalMileStone);
        if (currentBalance == payoutMileStone1) {
            redeemableEther[msg.sender] += mileStone1Reward;
        }
        else if (currentBalance == payoutMileStone2) {
            redeemableEther[msg.sender] += mileStone2Reward;
        }
        else if (currentBalance == finalMileStone ) {
            redeemableEther[msg.sender] += finalReward;
        }
        depositedWei += msg.value;
        return;
    }

    function claimReward() public {
        // s'assure que le jeu est terminé
        require(depositedWei == finalMileStone);
        // s'assure qu'il y a une récompense à donner
        require(redeemableEther[msg.sender] > 0);
        redeemableEther[msg.sender] = 0;
        msg.sender.transfer(transferValue);
    }
 }

Ici, nous avons créé une nouvelle variable, depositWei , qui garde la trace de l’ether connu déposé, et c’est cette variable que nous utilisons pour nos tests. Notez que nous n’avons plus aucune référence à this.balance.

Autres exemples

Quelques exemples de contrats exploitables ont été donnés dans le Underhanded Solidity Coding Contest , qui fournit également des exemples détaillés d’un certain nombre de pièges soulevés dans cette section.

DELEGATECALL (Appel délégué)

Les opcodes CALL et DELEGATECALL sont utiles pour permettre aux développeurs Ethereum de modulariser leur code. Les appels de messages externes standard aux contrats sont gérés par l' opcode CALL, le code étant exécuté dans le contexte du contrat/de la fonction externe. L' opcode DELEGATECALL est presque identique, sauf que le code exécuté à l’adresse ciblée est exécuté dans le contexte du contrat appelant, et msg.sender et msg.value restent inchangés. Cette fonctionnalité permet la mise en œuvre de bibliothèques , permettant aux développeurs de déployer du code réutilisable une seule fois et de l’appeler à partir de futurs contrats.

Bien que les différences entre ces deux opcodes soient simples et intuitives, l’utilisation de DELEGATECALL peut conduire à l’exécution de code inattendu.

Pour plus de lecture, voir  Question Ethereum Stack Exchange sur ce sujet de Loi.Luu et la documentation Solidity .

La vulnérabilité

En raison de la nature de préservation du contexte de DELEGATECALL , la création de bibliothèques personnalisées sans vulnérabilité n’est pas aussi simple qu’on pourrait le penser. Le code des bibliothèques elles-mêmes peut être sécurisé et sans vulnérabilité ; cependant, lorsqu’il est exécuté dans le contexte d’une autre application, de nouvelles vulnérabilités peuvent survenir. Voyons un exemple assez complexe de cela, en utilisant des nombres de Fibonacci.

Considérez la bibliothèque dans FibonacciLib.sol, qui peut générer la séquence de Fibonacci et des séquences de forme similaire. (Remarque : ce code a été modifié à partir de https://bit.ly/2MReuii .)

Example 6. FibonacciLib.sol
// contrat de bibliothèque - calcule les nombres de type Fibonacci
contract FibonacciLib {
    // initialisation de la suite standard de Fibonacci
    uint public start;
    uint public calculatedFibNumber;

    // modifie le numéro zéro de la séquence
    function setStart(uint _start) public {
        start = _start;
    }

    function setFibonacci(uint n) public {
        calculatedFibNumber = fibonacci(n);
    }

    function fibonacci(uint n) internal returns (uint) {
        if (n == 0) return start;
        else if (n == 1) return start + 1;
        else return fibonacci(n - 1) + fibonacci(n - 2);
    }
}

Cette bibliothèque fournit une fonction qui peut générer le n-ème nombre de Fibonacci dans la séquence. Il permet aux utilisateurs de changer le numéro de départ de la séquence (start) et de calculer les n-èmes nombres de type Fibonacci dans cette nouvelle séquence.

Considérons maintenant un contrat qui utilise cette bibliothèque, montré dans FibonacciBalance.sol.

Example 7. FibonacciBalance.sol
contract FibonacciBalance {

    address public fibonacciLibrary;
    // le nombre de Fibonacci actuel à retirer
    uint public calculatedFibNumber;
    // le numéro de séquence de Fibonacci de départ
    uint public start = 3;
    uint public withdrawalCounter;
    // le sélecteur de fonction de Fibonancci
    bytes4 constant fibSig = bytes4(sha3("setFibonacci(uint256)"));

    // constructeur - charge le contrat avec ether
    constructor(address _fibonacciLibrary) external payable {
        fibonacciLibrary = _fibonacciLibrary;
    }

    function withdraw() {
        withdrawalCounter += 1;
        // calcule le nombre de Fibonacci pour l'utilisateur de retrait actuel-
        // ceci définit le nombreFibcalculé
        require(fibonacciLibrary.delegatecall(fibSig, withdrawalCounter));
        msg.sender.transfer(calculatedFibNumber * 1 ether);
    }

    // permet aux utilisateurs d'appeler les fonctions de la bibliothèque Fibonacci
    function() public {
        require(fibonacciLibrary.delegatecall(msg.data));
    }
}

Ce contrat permet à un participant de retirer des ethers du contrat, la quantité d’ethers étant égale au nombre de Fibonacci correspondant à l’ordre de retrait du participant ; c’est-à-dire que le premier participant obtient 1 ether, le second obtient également 1, le troisième obtient 2, le quatrième obtient 3, le cinquième 5, et ainsi de suite (jusqu’à ce que le solde du contrat soit inférieur au nombre de Fibonacci retiré).

Il y a un certain nombre d' éléments dans ce contrat qui peuvent nécessiter quelques explications. Tout d’abord, il existe une variable intéressante, fibSig. Cela contient les 4 premiers octets du hachage Keccak-256 (SHA-3) de la chaîne 'setFibonacci (uint256)'. Ceci est connu sous le nom de sélecteur de fonction et est placé dans calldata pour spécifier quelle fonction d’un contrat intelligent sera appelée. Il est utilisé dans la fonction d’appel délégué à la ligne 21 pour spécifier que nous souhaitons exécuter la fonction fibonacci(uint256). Le deuxième argument est delegatecall le paramètre que nous passons à la fonction. Deuxièmement, nous supposons que l’adresse de la bibliothèque FibonacciLib est correctement référencée dans le constructeur (Référencement des contrats externes traite de certaines vulnérabilités potentielles liées à ce type d’initialisation de référence de contrat).

Pouvez-vous repérer des erreurs dans ce contrat ? Si l’on déployait ce contrat, le remplissait d’ether et appelait withdraw, il reviendrait probablement.

Vous avez peut-être remarqué que la variable d’état start est utilisée à la fois dans la bibliothèque et dans le contrat d’appel principal. Dans le contrat de bibliothèque, start est utilisé pour spécifier le début de la séquence de Fibonacci et est défini sur 0 , alors qu’il est défini sur 3 dans le contrat appelant. Vous avez peut-être également remarqué que la fonction de secours dans le contrat FibonacciBalance permet de transmettre tous les appels au contrat de bibliothèque, ce qui permet d’appeler la fonction setStart du contrat de bibliothèque. En rappelant que nous préservons l’état du contrat, il peut sembler que cette fonction permettrait de changer l’état de la variable start dans le contrat local FibonnacciBalance. Si c’est le cas, cela permettrait de retirer plus d’ether, car le calculatedFibNumber résultant dépend de la variable start (comme indiqué dans le contrat de bibliothèque). En réalité , la fonction setStart ne modifie pas (et ne peut pas modifier) la variable start dans le contrat FibonacciBalance. La vulnérabilité sous-jacente de ce contrat est bien pire que la simple modification de la variable start.

Avant de discuter du problème réel, faisons un petit détour pour comprendre comment les variables d’état sont réellement stockées dans les contrats. Les variables d’état ou de stockage (variables qui persistent sur des transactions individuelles) sont placées dans des emplacements de manière séquentielle au fur et à mesure qu’elles sont introduites dans le contrat. (Il y a quelques complexités ici ; consultez la documentation de Solidity pour une compréhension plus approfondie.)

Prenons l’exemple du contrat de bibliothèque. Il a deux variables d’état, start et calculatedFibNumber . La première variable, start, est stockée dans le stockage du contrat à slot[0] (c’est-à-dire le premier slot (fente ou espace)). La deuxième variable, calculatedFibNumber, est placée dans le prochain emplacement de stockage disponible, slot[1]. La fonction setStart prend une entrée et définit start quelle que soit l’entrée. Cette fonction définit donc slot[0] sur l’entrée que nous fournissons dans la fonction setStart. De la même manière, la fonction setFibonacci définit la fonction calculatedFibNumber sur le résultat de fibonacci(n). Encore une fois, il s’agit simplement de définir storage slot[1] sur la valeur de fibonacci(n).

Regardons maintenant le contrat FibonacciBalance. Storage slot[0] correspond maintenant à l' adresse fibonacciLibrary , et slot[1] correspond à calculatedFibNumber . C’est dans ce mappage incorrect que la vulnérabilité se produit. delegatecall préserve le contexte du contrat . Cela signifie que le code exécuté via l’appel délégué delegatecall agira sur l’état (c’est-à-dire le stockage) du contrat appelant.

Notez maintenant que withdraw à la ligne 21, nous exécutons fibonacciLibrary.delegatecall(fibSig,withdrawalCounter). Cela appelle la fonction setFibonacci, qui, comme nous l’avons vu, modifie l’espace de stockage slot[1] , qui dans notre contexte actuel est calculatedFibNumber . C’est comme prévu (c’est-à-dire qu’après l’exécution, le calculatedFibNumber est modifié). Cependant, rappelez-vous que la variable de début dans le contrat FibonacciLib est située dans l’espace de stockage slot[0], qui est l' adresse fibonacciLibrary dans le contrat actuel. Cela signifie que la fonction fibonacci donnera un résultat inattendu. En effet, il fait référence à start ( slot[0] ), qui dans le contexte d’appel actuel est l' adresse fibonacciLibrary (qui sera souvent assez grande, lorsqu’elle est interprétée comme un uint). Ainsi , il est probable que la fonction de retrait withdraw reviendra, car elle ne contiendra pas la quantité uint(fibonacciLibrary) d’ether, ce qui est ce que calculatedFibNumber renverra.

Pire encore, le contrat FibonacciBalance permet aux utilisateurs d’appeler toutes les fonctions fibonacciLibrary via la fonction de secours à la ligne 26. Comme nous l’avons vu précédemment, cela inclut la fonction setStart. Nous avons expliqué que cette fonction permet à quiconque de modifier ou de définir l' emplacement de stockage slot[0]. Dans ce cas, emplacement de stockage slot[0] est l' adresse fibonacciLibrary. Par conséquent, un attaquant pourrait créer un contrat malveillant, convertir l’adresse en uint (cela peut être fait facilement en Python en utilisant int('<address>',16) ), puis appeler setStart ([attack_contract_address_as_uint]) . Cela changera fibonacciLibrary à l’adresse du contrat d’attaque. Ensuite, chaque fois qu’un utilisateur appelle withdraw ou la fonction de secours, le contrat malveillant s’exécutera (ce qui peut voler la totalité du solde du contrat) car nous avons modifié l’adresse réelle de fibonacciLibrary. Un exemple d’un tel contrat d’attaque serait :

contract Attack {
    uint storageSlot0; // correspond à fibonacciLibrary
    uint storageSlot1; // correspond à calculatedFibNumber

    // défaut - cela s'exécutera si une fonction spécifiée n'est pas trouvée
    function() public {
        storageSlot1 = 0; // nous définissons calculateFibNumber sur 0, donc si retirer
        // s'appelle nous n'envoyons pas d'ether
        <attacker_address>.transfer(this.balance); // on prend tout l'ether
    }
 }

Notez que ce contrat d’attaque modifie le calculatedFibNumber en changeant l’espace de stockage slot[1]. En principe, un attaquant pourrait modifier n’importe quel autre emplacement de stockage de son choix, pour effectuer toutes sortes d’attaques sur ce contrat. Nous vous encourageons à mettre ces contrats dans Remix et à expérimenter différents contrats d’attaque et changements d’état via ces fonctions d’appel délégué delegatecall.

Il est également important de noter que lorsque nous disons que l’appel delegatecall préserve l’état, nous ne parlons pas des noms de variables du contrat, mais plutôt des emplacements de stockage réels vers lesquels ces noms pointent. Comme vous pouvez le voir dans cet exemple, une simple erreur peut conduire un attaquant à détourner l’intégralité du contrat et de son ether.

Techniques préventives

Solidity fournit le mot-clé de library (bibliothèque) pour la mise en œuvre des contrats de bibliothèque (voir la documentation pour plus de détails). Cela garantit que le contrat de bibliothèque est sans état et non autodestructible. Forcer les bibliothèques à être sans état atténue les complexités du contexte de stockage démontrées dans cette section. Les bibliothèques sans état empêchent également les attaques dans lesquelles les attaquants modifient directement l’état de la bibliothèque afin d' affecter les contrats qui dépendent du code de la bibliothèque. En règle générale, lorsque vous utilisez DELEGATECALL, faites très attention au contexte d’appel possible du contrat de bibliothèque et du contrat d’appel, et chaque fois que possible, créez des bibliothèques. sans état.

Exemple concret : Parity Multisig Wallet (Second Hack)

Le deuxième hack de Parity Multisig Wallet est un exemple de la façon dont un code de bibliothèque bien écrit peut être exploité s’il est exécuté en dehors de son contexte prévu. Il existe un certain nombre de bonnes explications de ce hack, telles que "Parity Multisig Hacked. Encore une fois » et « Un examen approfondi du bogue multisig de parity ».

Pour compléter ces références, explorons les contrats qui ont été exploités. Les contrats de bibliothèque et de portefeuille peuvent être trouvés sur GitHub .

Le contrat de bibliothèque est le suivant :

contract WalletLibrary is WalletEvents {

  ...

  // lancer sauf si le contrat n'est pas encore initialisé.
  modifier only_uninitialized { if (m_numOwners > 0) throw; _; }

  // constructeur - il suffit de transmettre le tableau propriétaire à multipropriété et
  // la limite à daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit)
      only_uninitialized {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }

  // tue le contrat en envoyant tout à `_to`.
  function kill(address _to) onlymanyowners(sha3(msg.data)) external {
    suicide(_to);
  }

  ...

}

Et voici le contrat de portefeuille :

contract Wallet is WalletEvents {

  ...
  // MÉTHODES

  // est appelé lorsqu'aucune autre fonction ne correspond
  function() payable {
    // juste envoyer de l' argent ?
    if (msg.value > 0)
      Deposit(msg.sender, msg.value);
    else if (msg.data.length > 0)
      _walletLibrary.delegatecall(msg.data);
  }

  ...

  // DES CHAMPS
  address constant _walletLibrary =
    0xcafecafecafecafecafecafecafecafecafecafe;
}

Notez que le contrat Wallet transmet essentiellement tous les appels au contrat WalletLibrary via un appel délégué. L’adresse constante _walletLibrary dans cet extrait de code agit comme un espace réservé pour le déploiement réel de contrat WalletLibrary (qui était à 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4 ).

L’opération voulue de ces contrats est d’avoir un portefeuille Wallet déployable à faible coût dont la base de code et les principales fonctionnalités se trouvaient dans le contrat WalletLibrary. Malheureusement, le contrat WalletLibrary est lui-même un contrat et conserve son propre état. Pouvez-vous voir pourquoi cela pourrait être un problème?

Il est possible d’envoyer des appels au contrat WalletLibrary lui-même. Plus précisément, le contrat WalletLibrary pourrait être initialisé et devenir propriétaire. En fait, un utilisateur a fait cela, appelant la fonction initWallet sur le contrat WalletLibrary et devenant propriétaire du contrat de bibliothèque. Le même utilisateur a ensuite appelé la fonction kill . Étant donné que l’utilisateur était propriétaire du contrat de bibliothèque, le modificateur a été adopté et le contrat de bibliothèque s’est auto-détruit. Comme tous les contrats Wallet existants se réfèrent à ce contrat de bibliothèque et ne contiennent aucune méthode pour modifier cette référence, toutes leurs fonctionnalités, y compris la possibilité de retirer de l’ether, ont été perdues avec le contrat WalletLibrary . En conséquence, tout l’ether de tous les portefeuilles multisig Parity de ce type a été instantanément perdu ou définitivement irrécupérable..

Visibilités par défaut

Les fonctions dans Solidity ont des spécificateurs de visibilité qui dictent comment elles peuvent être appelées. La visibilité détermine si une fonction peut être appelée en externe par les utilisateurs, par d’autres contrats dérivés, uniquement en interne ou uniquement en externe. Il existe quatre spécificateurs de visibilité, qui sont décrits en détail dans la documentation Solidity. Les fonctions sont par défaut à public , permettant aux utilisateurs de les appeler de l’extérieur. Nous allons maintenant voir comment une utilisation incorrecte des spécificateurs de visibilité peut entraîner des vulnérabilités dévastatrices dans les contrats intelligents.

La vulnérabilité

La visibilité par défaut des fonctions est public , donc les fonctions qui ne spécifient pas leur visibilité pourront être appelées par des utilisateurs externes. Le problème survient lorsque les développeurs omettent par erreur les spécificateurs de visibilité sur les fonctions qui devraient être privées (ou uniquement appelables dans le contrat lui-même).

Explorons rapidement un exemple trivial :

contract HashForEther {

    function withdrawWinnings() {
        // Gagnant si les 8 derniers caractères hexadécimaux de l'adresse sont 0
        require(uint32(msg.sender) == 0);
        _sendWinnings();
     }

     function _sendWinnings() {
         msg.sender.transfer(this.balance);
     }
}

Ce contrat simple est conçu pour agir comme un jeu de prime de devinette d’adresse. Pour remporter le solde du contrat, un utilisateur doit générer une adresse Ethereum dont les 8 derniers caractères hexadécimaux sont 0. Une fois atteint, il peut appeler la fonction withdrawWinnings pour obtenir sa prime.

Malheureusement, la visibilité des fonctions n’a pas été précisée. En particulier, la fonction _sendWinnings est public (par défaut), et donc n’importe quelle adresse peut appeler cette fonction pour voler la prime.

Techniques préventives

Il est de bonne pratique de toujours spécifier la visibilité de toutes les fonctions dans un contrat, même si elles sont intentionnellement public. Les versions récentes de solc affichent un avertissement pour les fonctions qui n’ont pas de visibilité explicite définie, pour encourager cette pratique.

Exemple concret : Parity Multisig Wallet (premier hack)

Dans le premier multisig hack de Parity, environ 31 millions de dollars d’ether ont été volés, principalement dans trois portefeuilles. Un bon récapitulatif de la façon exacte dont cela a été fait est donné par Haseeb Qureshi.

Essentiellement, le portefeuille multisig est construit à partir d’un contrat Wallet de base, qui appelle un contrat de bibliothèque contenant la fonctionnalité de base (comme décrit dans Exemple concret : Parity Multisig Wallet (Second Hack)). Le contrat de bibliothèque contient le code pour initialiser le portefeuille, comme le montre l’extrait suivant :

contract WalletLibrary is WalletEvents {

  ...

  // MÉTHODES

  ...

  // le constructeur reçoit le nombre de sigs requis pour faire des
  // transactions protégés "seulement pour ces propriétaires" ainsi que
  //  la sélection des adresses capable de les confirmer
  function initMultiowned(address[] _owners, uint _required) {
    m_numOwners = _owners.length + 1;
    m_owners[1] = uint(msg.sender);
    m_ownerIndex[uint(msg.sender)] = 1;
    for (uint i = 0; i < _owners.length; ++i)
    {
      m_owners[2 + i] = uint(_owners[i]);
      m_ownerIndex[uint(_owners[i])] = 2 + i;
    }
    m_required = _required;
  }

  ...

  // constructeur - il suffit de transmettre le tableau propriétaire à multipropriété et
  // la limite à daylimit
  function initWallet(address[] _owners, uint _required, uint _daylimit) {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
  }
}

Notez qu’aucune des fonctions ne spécifie leur visibilité, donc les deux sont par défaut public . La fonction initWallet est appelée dans le constructeur du portefeuille et définit les propriétaires du portefeuille multisig comme on peut le voir dans la fonction initMultiowned . Étant donné que ces fonctions ont été accidentellement laissées publiques , un attaquant a pu appeler ces fonctions sur des contrats déployés, réinitialisant la propriété à l’adresse de l’attaquant. En tant que propriétaire, l’attaquant a ensuite vidé les portefeuilles de tout leur ether.

Illusion d’entropie

Toutes les transactions sur la chaîne de blocs Ethereum sont des opérations de transition d’état déterministes. Cela signifie que chaque transaction modifie l’état global de l' écosystème Ethereum de manière calculable, sans incertitude. Cela a pour implication fondamentale qu’il n’y a pas de source d’entropie ou d’aléatoire dans Ethereum. Atteindre une entropie décentralisée (aléatoire) est un problème bien connu pour lequel de nombreuses solutions ont été proposées, y compris RANDAO , ou en utilisant une chaîne de hachages, comme décrit par Vitalik Buterin dans le billet de blog « Validator Ordering and Randomness in PoS ».

La vulnérabilité

Certains des premiers contrats construits sur la plate-forme Ethereum étaient basés sur le jeu. Fondamentalement, le jeu nécessite de l’incertitude (quelque chose sur lequel parier), ce qui rend la construction d’un système de jeu sur la chaîne de blocs (un système déterministe) plutôt difficile. Il est clair que l' incertitude doit provenir d’une source extérieure à la chaîne de blocs. C’est possible pour les paris entre joueurs (voir par exemple la technique commit–reveal) ; cependant, c’est beaucoup plus difficile si vous voulez mettre en place un contrat pour agir en tant que "croupier" (comme au blackjack ou à la roulette). Un écueil courant consiste à utiliser des variables de bloc futures, c’est-à-dire des variables contenant des informations sur le bloc de transaction dont les valeurs ne sont pas encore connues, telles que des hachages, des horodatages, des numéros de bloc ou des limites de gaz. Le problème avec ceux-ci est qu’ils sont contrôlés par le mineur qui exploite le bloc et, en tant que tels, ne sont pas vraiment aléatoires. Considérez, par exemple, un contrat intelligent de roulette avec une logique qui renvoie un nombre noir si le hachage du bloc suivant se termine par un nombre pair. Un mineur (ou pool de mineurs) pourrait parier 1 million de dollars sur le noir. S’ils résolvent le bloc suivant et trouvent que le hachage se termine par un nombre impair, ils pourraient heureusement ne pas publier leur bloc et en exploiter un autre, jusqu’à ce qu’ils trouvent une solution avec le hachage du bloc étant un nombre pair (en supposant que la récompense du bloc et les frais sont inférieurs à 1 M$). L’utilisation de variables passées ou présentes peut être encore plus dévastatrice, comme le démontre Martin Swende dans son excellent article de blog. De plus, utiliser uniquement des variables de bloc signifie que le nombre pseudo-aléatoire sera le même pour toutes les transactions d’un bloc, de sorte qu’un attaquant peut multiplier ses gains en effectuant de nombreuses transactions dans un bloc (si il devait y avoir une mise maximale).

Techniques préventives

La source d’entropie (aléatoire) doit être externe à la chaîne de blocs. Cela peut être fait entre pairs avec des systèmes tels que commit–reveal, ou en changeant le modèle de confiance en un groupe de participants (comme dans RandDAO). Cela peut également être fait via une entité centralisée qui agit comme un oracle aléatoire. Les variables de bloc (en général, il y a quelques exceptions) ne doivent pas être utilisées pour générer de l’entropie, car elles peuvent être manipulées par les mineurs.

Exemple concret : contrats PRNG

En février 2018 Arseny Reutov a blogué sur son analyse de 3 649 contrats intelligents en direct qui utilisaient une sorte de générateur de nombres pseudo-aléatoires (PRNG) ; il a trouvé 43 contrats qui pourraient être exploités.

Référencement des contrats externes

L’un des avantages de "l’ordinateur mondial" Ethereum est la possibilité de réutiliser du code et d’interagir avec des contrats déjà déployés sur le réseau. En conséquence, un grand nombre de contrats font référence à des contrats externes, généralement via des appels de messages externes. Ces appels de messages externes peuvent masquer les intentions des acteurs malveillants de certaines manières non évidentes, que nous allons maintenant examiner.

La vulnérabilité

Dans Solidity, n’importe quelle adresse peut être convertie en contrat, que le code à l’adresse représente ou non le type de contrat en cours de conversion. Cela peut causer des problèmes, en particulier lorsque l’auteur du contrat tente de dissimuler un code malveillant. Illustrons cela par un exemple.

Considérez un morceau de code comme Rot13Encryption.sol, qui implémente rudimentairement le chiffrement ROT13 .

Example 8. Rot13Encryption.sol
// contrat de chiffrement
contract Rot13Encryption {

   event Result(string convertedString);

    // rot13-chiffre une chaîne
    function rot13Encrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            // assemblage en ligne pour modifier la chaîne
            assembly {
                // récupère le premier octet
                char := byte(0,char)
                // si le caractère est dans [ n,z ], c'est-à-dire l'enveloppant
                if and(gt(char,0x6D), lt(char,0x7B))
                // soustraire du nombre ASCII 'a',
                // la différence entre le caractère <char> et 'z'
                { char:= sub(0x60, sub(0x7A,char)) }
                if iszero(eq(char, 0x20)) // ignore espaces
                // ajoute 13 au caractère
                {mstore8(add(add(text,0x20), mul(i,1)), add(char,13))}
            }
        }
        emit Result(text);
    }

    // rot13-déchiffre une chaîne
    function rot13Decrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            assembly {
                char := byte(0,char)
                if and(gt(char,0x60), lt(char,0x6E))
                { char:= add(0x7B, sub(char,0x61)) }
                if iszero(eq(char, 0x20))
                {mstore8(add(add(text,0x20), mul(i,1)), sub(char,13))}
            }
        }
        emit Result(text);
    }
}

Ce code prend simplement une chaîne (lettres a à z, sans validation) et l'encrypte en décalant chaque caractère de 13 positions vers la droite (en s’enroulant autour de z) ; c’est-à-dire a passe à n et x passe à k. L’assemblage dans le contrat précédent n’a pas besoin d’être compris pour apprécier le problème discuté, de sorte que les lecteurs non familiarisés avec l’assemblage peuvent l’ignorer en toute sécurité.

Considérons maintenant le contrat suivant, qui utilise ce code pour son chiffrement :

import "Rot13Encryption.sol";

// crypte vos informations top-secrètes
contract EncryptionContract {
    // bibliothèque pour le chiffrement
    Rot13Encryption encryptionLibrary;

    // constructeur - initialise la bibliothèque
    constructor(Rot13Encryption _encryptionLibrary) {
        encryptionLibrary = _encryptionLibrary;
    }

    function encryptPrivateData(string privateInfo) {
        // faire potentiellement quelques opérations ici
        encryptionLibrary.rot13Encrypt(privateInfo);
     }
 }

Le problème avec ce contrat est que l’adresse de encryptionLibrary n’est pas publique ou constante. Ainsi, le déployeur du contrat pourrait donner une adresse dans le constructeur qui pointe vers ce contrat :

// contrat de chiffrement
contract Rot26Encryption {

   event Result(string convertedString);

    // rot13-encrypte une chaîne
    function rot13Encrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            // assemblage en ligne pour modifier la chaîne
            assembly {
                // récupère le premier octet
                char := byte(0,char)
                // si le caractère est dans [ n,z ], c'est-à-dire l'enveloppant
                if and(gt(char,0x6D), lt(char,0x7B))
                // soustraire du nombre ASCII 'a',
                // la différence entre le caractère <char> et 'z'
                { char:= sub(0x60, sub(0x7A,char)) }
                // ignore les espaces
                if iszero(eq(char, 0x20))
                // ajoute 26 au caractère !
                {mstore8(add(add(text,0x20), mul(i,1)), add(char,26))}
            }
        }
        emit Result(text);
    }

    // rot13-décrypte une chaîne
    function rot13Decrypt (string text) public {
        uint256 length = bytes(text).length;
        for (var i = 0; i < length; i++) {
            byte char = bytes(text)[i];
            assembly {
                char := byte(0,char)
                if and(gt(char,0x60), lt(char,0x6E))
                { char:= add(0x7B, sub(char,0x61)) }
                if iszero(eq(char, 0x20))
                {mstore8(add(add(text,0x20), mul(i,1)), sub(char,26))}
            }
        }
        emit Result(text);
    }
}

Ce contrat implémente le chiffrement ROT26, qui décale chaque caractère de 26 places (c’est-à-dire, ne fait rien). Encore une fois, il n’est pas nécessaire de comprendre l’assemblage dans ce contrat. Plus simplement, l’attaquant aurait pu lier le contrat suivant au même effet :

contract Print{
    event Print(string text);

    function rot13Encrypt(string text) public {
        emit Print(text);
    }
 }

Si l’adresse de l’un de ces contrats était donnée dans le constructeur, la fonction encryptPrivateData produirait simplement un événement qui imprime les données privées non chiffrées.

Bien que dans cet exemple, un contrat de type bibliothèque ait été défini dans le constructeur, il arrive souvent qu’un utilisateur privilégié (tel qu’un propriétaire) puisse modifier les adresses de contrat de bibliothèque. Si un contrat lié ne contient pas la fonction appelée, la fonction de secours s’exécutera. Par exemple, avec la ligne encryptionLibrary.rot13​Encrypt(), si le contrat spécifié par encryptionLibrary était :

 contract Blank {
     event Print(string text);
     function () {
         emit Print("Here");
         // placez le code malveillant ici et il s'exécutera
     }
 }

alors un événement avec le texte Here serait émis. Ainsi, si les utilisateurs peuvent modifier les bibliothèques de contrats, ils peuvent en principe amener d’autres utilisateurs à exécuter du code arbitraire sans le savoir.

Warning

Les contrats représentés ici sont uniquement à des fins de démonstration et ne représentent pas un cryptage approprié. Ils ne devrait pas être utilisé pour le cryptage .

Techniques préventives

Comme démontré précédemment, les contrats sûrs peuvent (dans certains cas) être déployés de telle manière qu’ils se comportent de manière malveillante. Un auditeur pourrait vérifier publiquement un contrat et demander à son propriétaire de le déployer de manière malveillante, ce qui entraînerait un contrat audité publiquement présentant des vulnérabilités ou une intention malveillante.

Il existe un certain nombre de techniques qui empêchent ces scénarios.

Une technique consiste à utiliser le mot-clé new pour créer des contrats. Dans l’exemple précédent, le constructeur pourrait s’écrire :

constructor() {
    encryptionLibrary = new Rot13Encryption();
}

de cette manière, une instance du contrat référencé est créée au moment du déploiement et le déployeur ne peut pas remplacer le contrat Rot13Encryption sans le modifier.

Une autre solution consiste à coder en dur les adresses de contrat externes.

En général, le code qui appelle des contrats externes doit toujours être audité avec soin. En tant que développeur, lors de la définition de contrats externes, il peut être judicieux de rendre publiques les adresses des contrats (ce qui n’est pas le cas dans l’exemple du pot de miel de la section suivante) pour permettre aux utilisateurs d’examiner facilement le code référencé par le contrat. Inversement, si un contrat a une adresse de contrat en variable privée, cela peut être le signe d’un comportement malveillant (comme le montre l’exemple du monde réel). Si un utilisateur peut modifier une adresse de contrat utilisée pour appeler des fonctions externes, il peut être important (dans un contexte de système décentralisé) d’implémenter un mécanisme de verrouillage du temps et/ou de vote pour permettre aux utilisateurs de voir quel code est modifié, ou pour donner aux participants la possibilité de s’inscrire ou s’abstenir avec la nouvelle adresse contractuelle.

Exemple concret : pot de miel de réentrance

Un certain nombre de pots de miel récents ont été publiés sur le réseau principal. Ces contrats tentent de déjouer les pirates Ethereum qui tentent d’exploiter les contrats, mais qui finissent par perdre de l’ether au profit du contrat qu’ils s’attendent à exploiter. Un exemple utilise cette attaque en remplaçant un contrat attendu par un contrat malveillant dans le constructeur. Le code se trouve ici :

pragma solidity ^0.4.19;

contract Private_Bank
{
    mapping (address => uint) public balances;
    uint public MinDeposit = 1 ether;
    Log TransferLog;

    function Private_Bank(address _log)
    {
        TransferLog = Log(_log);
    }

    function Deposit()
    public
    payable
    {
        if(msg.value >= MinDeposit)
        {
            balances[msg.sender]+=msg.value;
            TransferLog.AddMessage(msg.sender,msg.value,"Deposit");
        }
    }

    function CashOut(uint _am)
    {
        if(_am<=balances[msg.sender])
        {
            if(msg.sender.call.value(_am)())
            {
                balances[msg.sender]-=_am;
                TransferLog.AddMessage(msg.sender,_am,"CashOut");
            }
        }
    }

    function() external payable{}

}

contract Log
{
    struct Message
    {
        address Sender;
        string  Data;
        uint Val;
        uint  Time;
    }

    Message[] public History;
    Message LastMsg;

    function AddMessage(address _adr,uint _val,string _data)
    public
    {
        LastMsg.Sender = _adr;
        LastMsg.Time = now;
        LastMsg.Val = _val;
        LastMsg.Data = _data;
        History.push(LastMsg);
    }
}

Ce message d’un utilisateur de reddit explique comment il a perdu 1 ether à cause de ce contrat en essayant d’exploiter le bogue de réentrance qu’il s’attendait à trouver dans le contrat..

Attaque par adresse courte/paramètre

Cette attaque n’est pas effectuée sur les contrats Solidity eux-mêmes, mais sur des applications tierces susceptibles d’interagir avec eux. Cette section est ajoutée par souci d’exhaustivité et pour donner au lecteur une idée de la façon dont les paramètres peuvent être manipulés dans les contrats.

La vulnérabilité

Lors de la transmission de paramètres à un contrat intelligent, les paramètres sont codés conformément à la spécification ABI . Il est possible d’envoyer des paramètres codés plus courts que la longueur de paramètre attendue (par exemple, envoyer une adresse qui ne contient que 38 caractères hexadécimaux (19 octets) au lieu des 40 caractères hexadécimaux standard (20 octets)). Dans un tel scénario, l’EVM ajoutera des zéros à la fin des paramètres codés pour compenser la longueur attendue.

Cela devient un problème lorsque les applications tierces ne valident pas les entrées. L’exemple le plus clair est un échange qui ne vérifie pas l’adresse d’un jeton ERC20 lorsqu’un utilisateur demande un retrait. Cet exemple est traité plus en détail dans l’article de Peter Vessenes , "The ERC20 Short Address Attack Explained" .

Considérez l’interface de fonction de transfert standard ERC20 , en notant l’ordre des paramètres :

function transfer(address to, uint tokens) public returns (bool success);

Considérons maintenant un échange détenant une grande quantité d’un jeton (disons REP) et un utilisateur qui souhaite retirer sa part de 100 jetons. L’utilisateur soumettrait son adresse, 0xdeaddeaddeaddeaddeaddeaddeaddeaddeaddead , et le nombre de jetons, 100. L’échange coderait ces paramètres dans l’ordre spécifié par la fonction transfer; c’est-à-dire address puis tokens . Le résultat encodé serait :

a9059cbb000000000000000000000000deaddeaddea \
ddeaddeaddeaddeaddeaddeaddead0000000000000
000000000000000000000000000000000056bc75e2d63100000

Les 4 premiers octets ( a9059cbb ) sont la signature ou le sélecteur de fonction de transfer, les 32 octets suivants sont l’adresse et les 32 derniers octets représentent le nombre uint256 de jetons. Notez que l’hex 56bc75e2d63100000 à la fin correspond à 100 jetons (avec 18 décimales, comme spécifié par le contrat de jeton REP ).

Voyons maintenant ce qui se passerait si l’on envoyait une adresse à laquelle il manquait 1 octet (2 chiffres hexadécimaux). Plus précisément, disons qu’un attaquant envoie 0xdeaddeaddeaddeaddeaddeaddeaddeadde comme adresse (il manque les deux derniers chiffres) et les mêmes 100 jetons à retirer. Si l’échange ne valide pas cette entrée, elle sera encodée comme :

a9059cbb000000000000000000000000deaddeaddea \
ddeaddeaddeaddeaddeaddeadde00000000000000
00000000000000000000000000000000056bc75e2d6310000000

La différence est subtile. Notez que 00 a été ajouté à la fin de l’encodage, pour compenser l’adresse courte qui a été envoyée. Lorsque cela est envoyé au contrat intelligent, les paramètres de address seront lus comme 0xdeaddeaddeaddeaddeaddeaddeaddeadde00 et la valeur sera lue comme 56bc75e2d6310000000 (notez les deux 0 supplémentaires). Cette valeur est maintenant de 25600 jetons (la valeur a été multipliée par 256). Dans cet exemple, si l’échange contenait autant de jetons, l’utilisateur retirerait 25600 jetons (alors que l’échange pense que l’utilisateur n’en retire que 100) à l’adresse modifiée. Évidemment , l’attaquant ne possédera pas l’adresse modifiée dans cet exemple, mais si l’attaquant devait générer une adresse qui se terminait par des 0 (qui peut être facilement forcée brutalement) et utilisait cette adresse générée, il pourrait voler des jetons à l’échange sans méfiance.

Techniques préventives

Tous les paramètres d’entrée dans les applications externes doivent être validés avant de les envoyer à la chaîne de blocs. Il convient également de noter que l’ordre des paramètres joue ici un rôle important. Comme le rembourrage ne se produit qu’à la fin, un ordre minutieux des paramètres dans le contrat intelligent peut atténuer certaines formes de cette attaque.

Valeurs de retour CALL non vérifiés

Il existe plusieurs façons d’effectuer des appels externes dans Solidity. L’envoi d’ether à des comptes externes est généralement effectué via la méthode transfer . Cependant, la fonction d’envoi send peut également être utilisée, et pour des appels externes plus polyvalents, l' opcode CALL peut être directement utilisé dans Solidity. Les fonctions d’appel call et d’envoi send renvoient un booléen indiquant si l’appel a réussi ou échoué. Ainsi, ces fonctions ont une simple mise en garde, en ce sens que la transaction qui exécute ces fonctions ne reviendra pas si l’appel externe ( initialisé par call ou send ) échoue; à la place, les fonctions renverront simplement false. Une erreur courante est que le développeur s’attend à ce qu’un retour se produise si l’appel externe échoue et ne vérifie pas la valeur de retour.

La vulnérabilité

Considérez l’exemple suivant :

contract Lotto {

    bool public payedOut = false;
    address public winner;
    uint public winAmount;

    // ... fonctionnalité supplémentaire ici

    function sendToWinner() public {
        require(!payedOut);
        winner.send(winAmount);
        payedOut = true;
    }

    function withdrawLeftOver() public {
        require(payedOut);
        msg.sender.send(this.balance);
    }
}

Cela représente un contrat de type Lotto, où un gagnant winner reçoit une quantité d’ether (winAmount), ce qui laisse généralement un peu de reste à retirer.

La vulnérabilité existe sur la ligne 11, où un envoi send est utilisé sans vérifier la réponse. Dans cet exemple trivial, un gagnant winner dont la transaction échoue (soit parce qu’il est à court d’essence, soit parce qu’il s’agit d’un contrat qui lance intentionnellement la fonction de secours) permet à payedOut d’être défini sur true, que l’ether ait été envoyé ou non. Dans ce cas, n’importe qui peut retirer les gains du gagnant winner via la fonction withdrawLeftOver.

Techniques préventives

Dans la mesure du possible, utilisez la fonction transfer plutôt que send, car le transfert sera annulé si la transaction externe est annulée. Si un envoi send est requis, vérifiez toujours la valeur de retour.

Une recommandation plus robuste est d’adopter un modèle de retrait . Dans cette solution, chaque utilisateur doit appeler une fonction de retrait isolée qui gère l’envoi d’ether hors du contrat et traite les conséquences des transactions d’envoi échouées. L’idée est d’isoler logiquement la fonctionnalité d’envoi externe du reste de la base de code et de placer le fardeau d’une transaction potentiellement échouée sur l’utilisateur final appelant la fonction de retrait.

Exemple concret : Etherpot et King of the Ether

Etherpot était une loterie de contrats intelligents, pas trop différente de l’exemple de contrat mentionné précédemment. La chute de ce contrat était principalement due à une utilisation incorrecte des hachages de bloc (seuls les 256 derniers hachages de bloc sont utilisables ; voir le post d’Aakil Fernandes sur la manière dont Etherpot n’a pas réussi à en tenir compte correctement). Cependant, ce contrat a également souffert d’une valeur d’appel non contrôlée. Considérez la fonction cash dans lotto.sol: Code snippet.

Example 9. lotto.sol: Code snippet
...
  function cash(uint roundIndex, uint subpotIndex){

        var subpotsCount = getSubpotsCount(roundIndex);

        if(subpotIndex>=subpotsCount)
            return;

        var decisionBlockNumber = getDecisionBlockNumber(roundIndex,subpotIndex);

        if(decisionBlockNumber>block.number)
            return;

        if(rounds[roundIndex].isCashed[subpotIndex])
            return;
        // Les sous-cagnottes ne peuvent être encaissés qu'une seule fois. C'est pour éviter les doubles paiements
        var winner = calculateWinner(roundIndex,subpotIndex);
        var subpot = getSubpot(roundIndex);

        winner.send(subpot);

        rounds[roundIndex].isCashed[subpotIndex] = true;
        // Marquer le tour comme encaissé
}
...

Notez qu’à la ligne 21, la valeur de retour de la fonction send n’est pas vérifiée, et la ligne suivante définit alors un booléen indiquant que le gagnant a reçu ses fonds. Ce bogue peut autoriser un état où le gagnant ne reçoit pas son ether, mais l’état du contrat peut indiquer que le gagnant a déjà été payé.

Une version plus sérieuse de ce bogue s’est produite dans le King of the Ether. Un excellent post-mortem de ce contrat a été écrit qui détaille comment un envoi échoué non contrôlé pourrait être utilisé pour attaquer le contrat.

Conditions de course/devancement

La combinaison d’appels externes à d’autres contrats et la nature multi-utilisateurs de la chaîne de blocs sous-jacente donnent lieu à une variété de pièges potentiels de Solidity dans lesquels les utilisateurs font la course à l’exécution du code pour obtenir des états inattendus. La réentrance (discutée plus haut dans ce chapitre) est un exemple d’une telle condition de concurrence. Dans cette section, nous discuterons d’autres types de conditions de concurrence pouvant survenir sur la chaîne de blocs Ethereum. Il existe une variété de bons articles sur ce sujet, y compris "Race Conditions" sur le Wiki Ethereum , #7 sur le DASP Top10 de 2018 , et les meilleures pratiques pour les contrats intelligents Ethereum .

La vulnérabilité

Comme avec la plupart des chaînes de blocs, les nœuds Ethereum regroupent les transactions et les forment en blocs. Les transactions ne sont considérées comme valides qu’une fois qu’un mineur a résolu un mécanisme de consensus (actuellement Ethash PoW (preuve de travail) pour Ethereum). Le mineur qui résout le bloc choisit également les transactions du bassin qui seront incluses dans le bloc, généralement classées par le gasPrice de chaque transaction. Voici un vecteur d’attaque potentiel. Un attaquant peut surveiller le bassin de transactions à la recherche de transactions susceptibles de contenir des solutions à des problèmes, et modifier ou révoquer les autorisations du solveur ou changer l’état d’un contrat au détriment du solveur. L’attaquant peut alors obtenir les données de cette transaction et créer sa propre transaction avec un gasPrice plus élevé afin que sa transaction soit incluse dans un bloc avant l’original.

Voyons comment cela pourrait fonctionner avec un exemple simple. Considérez le contrat affiché dans FindThisHash.sol.

Example 10. FindThisHash.sol
contract FindThisHash {
    bytes32 constant public hash =
      0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a;

    constructor() external payable {} // charge de l'ether

    function solve(string solution) public {
        // Si vous pouvez trouver la pré-image du hachage, recevez 1000 ether
        require(hash == sha3(solution));
        msg.sender.transfer(1000 ether);
    }
}

Supposons que ce contrat contienne 1 000 ether . L’utilisateur qui peut trouver la préimage du hachage SHA-3 suivant :

0xb5b5b97fafd9855eec9b41f74dfb6c38f5951141f9a3ecd7f44d5479b630ee0a

peut soumettre la solution et récupérer les 1 000 ethers . Disons qu’un utilisateur découvre que la solution est Ethereum!. Ils appellent resolve (résoudre) avec Ethereum! comme paramètre. Malheureusement, un attaquant a été assez intelligent pour surveiller le bassin de transactions pour toute personne soumettant une solution. Ils voient cette solution, vérifient sa validité, puis soumettent une transaction équivalente avec un gasPrice beaucoup plus élevé que la transaction d’origine. Le mineur qui résout le bloc donnera probablement la préférence à l’attaquant en raison du gasPrice plus élevé et exploitera sa transaction avant celle du solveur d’origine. L’attaquant prendra les 1 000 ethers et l’utilisateur qui a résolu le problème n’obtiendra rien. Gardez à l’esprit que dans ce type de vulnérabilité de "devancement", les mineurs sont particulièrement incités à exécuter les attaques eux-mêmes (ou peuvent être soudoyés pour exécuter ces attaques avec des frais extravagants). La possibilité que l’attaquant soit lui-même un mineur ne doit pas être sous-estimée.

Techniques préventives

Il existe deux classes d’acteurs qui peuvent effectuer ce type d’attaques de devancement: les utilisateurs (qui modifient la valeur du gasPrice de leurs transactions) et les mineurs eux-mêmes (qui peuvent réorganiser les transactions dans un bloc comme ils l’entendent). Un contrat vulnérable à la première classe (utilisateurs) est nettement moins bien loti qu’un contrat vulnérable à la seconde (mineurs), car les mineurs ne peuvent effectuer l’attaque que lorsqu’ils résolvent un bloc, ce qui est peu probable pour un mineur individuel ciblant un bloc spécifique. Nous énumérerons ici quelques mesures d’atténuation relatives aux deux classes d’attaquants.

Une méthode consiste à placer une limite supérieure sur le gasPrice. Cela empêche les utilisateurs d’augmenter le gasPrice et d’obtenir un ordre de transaction préférentiel au-delà de la limite supérieure. Cette mesure protège uniquement contre la première classe d’attaquants (utilisateurs arbitraires). Les mineurs dans ce scénario peuvent toujours attaquer le contrat, car ils peuvent commander les transactions dans leur bloc comme ils le souhaitent, quel que soit le prix du gaz.

Une méthode plus robuste consiste à utiliser un schéma de validation-révélation. Un tel système dicte que les utilisateurs envoient des transactions avec des informations cachées (généralement un hachage). Une fois la transaction incluse dans un bloc, l’utilisateur envoie une transaction révélant les données qui ont été envoyées (la phase de révélation). Cette méthode empêche les mineurs et les utilisateurs d’exécuter des transactions en amont, car ils ne peuvent pas déterminer le contenu de la transaction. Cette méthode, cependant, ne peut pas dissimuler la valeur de la transaction (qui, dans certains cas, est l’information précieuse qui doit être cachée). Le contrat intelligent ENS permettait aux utilisateurs d’envoyer des transactions dont les données engagées comprenaient la quantité d’ether qu’ils étaient prêts à dépenser. Les utilisateurs pourraient alors envoyer des transactions de valeur arbitraire. Au cours de la phase de révélation, les utilisateurs ont été remboursés de la différence entre le montant envoyé lors de la transaction et le montant qu’ils étaient prêts à dépenser.

Une autre suggestion de Lorenz Breidenbach, Phil Daian , Ari Juels et Florian Tramèr est d’utiliser des "envois sous-marins". Une implémentation efficace de cette idée nécessite l' opcode CREATE2, qui n’a actuellement pas été adopté mais semble susceptible de l’être dans les embranchements à venir.

Exemples concrets : ERC20 et Bancor

La norme ERC20 est assez connue pour la construction de jetons sur Ethereum. Cette norme présente une vulnérabilité de devancement potentielle due à la fonction d’approbation approve. Mikhail Vladimirov et Dmitry Khovratovich ont écrit une bonne explication de cette vulnérabilité (et des moyens d’atténuer l’attaque).

La norme spécifie la fonction d' approbation approve comme suit :

function approve(address _spender, uint256 _value) returns (bool success)

Cette fonction permet à un utilisateur d’autoriser d’autres utilisateurs à transférer des jetons en son nom. La vulnérabilité de devancement se produit dans le scénario où une utilisatrice Alice autorise son ami Bob à dépenser 100 jetons. Alice décide plus tard qu’elle veut révoquer l’autorisation de Bob de dépenser, disons, 100 jetons, alors elle crée une transaction qui fixe l’allocation de Bob à 50 jetons. Bob, qui a observé attentivement la chaîne, voit cette transaction et construit sa propre transaction en dépensant les 100 jetons. Il met un gasPrice plus élevé sur sa transaction que celle d’Alice, donc sa transaction est prioritaire sur la sienne. Certaines implémentations d' approbation approve permettraient à Bob de transférer ses 100 jetons, puis, lorsque la transaction d’Alice est validée, réinitialiserait l’approbation de Bob à 50 jetons, donnant ainsi à Bob l’accès à 150 jetons.

Bancor est un autre exemple important dans le monde réel. Ivan Bogatyy et son équipe ont documenté une attaque rentable sur la mise en œuvre initiale de Bancor. Son article de blog et sa conférence DevCon3 expliquent en détail comment cela a été fait. Essentiellement, les prix des jetons sont déterminés en fonction de la valeur de la transaction ; les utilisateurs peuvent surveiller le bassin de transactions pour les transactions Bancor et les exécuter en amont pour profiter des différences de prix. Cette attaque a été traitée par l’équipe de Bancor.

Déni de service (DoS)

Cette catégorie est très large, mais se compose essentiellement d’attaques où les utilisateurs peuvent rendre un contrat inopérant pendant une période de temps, ou dans certains cas de façon permanente. Cela peut piéger l’ether dans ces contrats pour toujours, comme ce fut le cas dans Exemple concret : Parity Multisig Wallet (Second Hack).

La vulnérabilité

Un contrat peut devenir inopérant de différentes manières. Ici, nous mettons en évidence quelques modèles de codage Solidity moins évidents qui peuvent conduire à des vulnérabilités DoS :

Boucle à travers des mappages ou des tableaux manipulés en externe

Ce modèle apparaît généralement lorsqu’un propriétaire souhaite distribuer des jetons aux investisseurs avec une fonction de type distribution distribute, comme dans cet exemple de contrat :

contract DistributeTokens {
    address public owner; // est défini ailleurs
    address[] investors; // tableau d'investisseurs
    uint[] investorTokens; // quantité de jeton que reçoivent les investisseurs

    // ... fonctionnalité supplémentaire, y compris transfertoken()

    function invest() external payable {
        investors.push(msg.sender);
        investorTokens.push(msg.value * 5); // 5 fois de wei envoyé
        }

    function distribute() public {
        require(msg.sender == owner); // seulement le propriétaire
        for(uint i = 0; i < investors.length; i++) {
            // ici transferToken(to,amount) transfère le "montant" de
            // jetons à l'adresse "to"
            transferToken(investors[i],investorTokens[i]);
        }
    }
}

Notez que la boucle de ce contrat s’exécute sur un tableau qui peut être gonflé artificiellement. Un attaquant peut créer de nombreux comptes d’utilisateurs, ce qui agrandit le tableau des investisseurs investor. En principe, cela peut être fait de telle sorte que le gaz requis pour exécuter la boucle for dépasse la limite de gaz du bloc, rendant essentiellement la fonction de distribution distribute inopérante.

Opérations du propriétaire

Un autre modèle courant est celui où les propriétaires ont des privilèges spécifiques dans les contrats et doivent effectuer certaines tâches pour que le contrat passe à l’état suivant. Un exemple serait un contrat d’offre initiale de pièces (ICO) qui oblige le propriétaire à finaliser (finalize) le contrat, ce qui permet ensuite aux jetons d’être transférables. Par example:

bool public isFinalized = false;
address public owner; // est défini quelque part

function finalize() public {
    require(msg.sender == owner);
    isFinalized == true;
}

// ... fonctionnalité ICO supplémentaire

// fonction de transfert surchargée
function transfer(address _to, uint _value) returns (bool) {
    require(isFinalized);
    super.transfer(_to,_value)
}

...

Dans de tels cas, si l’utilisateur privilégié perd ses clés privées ou devient inactif, l’ensemble du contrat de jeton devient inopérant. Dans ce cas, si le propriétaire ne peut pas appeler finalize, aucun jeton ne peut être transféré ; l’ensemble du fonctionnement de l’écosystème de jetons repose sur une seule adresse.

État de progression basé sur les appels externes

Les contrats sont parfois rédigés de telle sorte que la progression vers un nouvel état nécessite l’envoi d’ether à une adresse ou l’attente d’une entrée d’une source externe. Ces modèles peuvent conduire à des attaques DoS lorsque l’appel externe échoue ou est empêché pour des raisons externes. Dans l’exemple de l’envoi d’ether, un utilisateur peut créer un contrat qui n’accepte pas l’ether. Si un contrat exige que l’ether soit retiré afin de passer à un nouvel état (considérez un contrat à verrouillage temporel qui exige que tout l’ether soit retiré avant d’être à nouveau utilisable), le contrat n’atteindra jamais le nouvel état, car l’ether ne peut jamais être envoyé au contrat de l’utilisateur qui n’accepte pas l’ether.

Techniques préventives

Dans le premier exemple, les contrats ne doivent pas boucler sur des structures de données pouvant être artificiellement manipulées par des utilisateurs externes. Un modèle de retrait est recommandé, dans lequel chacun des investisseurs appelle une fonction de retrait pour réclamer des jetons de manière indépendante.

Dans le deuxième exemple, un utilisateur privilégié devait modifier l’état du contrat. Dans de tels exemples, une sécurité intégrée peut être utilisée dans le cas où le propriétaire deviendrait incapable. Une solution consiste à faire du propriétaire un contrat multisig. Une autre solution consiste à utiliser un verrouillage temporel: dans l’exemple donné, le require à la ligne 5 pourrait inclure un mécanisme basé sur le temps, tel que require(msg.sender == owner || now > unlockTime) , qui permet à tout utilisateur de finaliser après une période de temps spécifiée par unlockTime. Ce type de technique d’atténuation peut également être utilisé dans le troisième exemple. Si les appels externes doivent progresser vers un nouvel état, tenez compte de leur échec possible et ajoutez éventuellement une progression d’état basée sur le temps dans le cas où l’appel souhaité n’arrive jamais.

Note

Bien sûr, il existe des alternatives centralisées à ces suggestions : on peut ajouter un maintenanceUser qui peut intervenir et résoudre les problèmes avec les vecteurs d’attaque basés sur DoS si besoin est. Généralement, ces types de contrats posent des problèmes de confiance, en raison du pouvoir d’une telle entité.

Exemples concrets : GouvernMental

GovernMental était un ancien schéma de Ponzi qui accumulait une assez grande quantité d’ether (1 100 ether, à un moment donné). Malheureusement, il était sensible aux vulnérabilités DoS mentionnées dans cette section. Un post Reddit par etherik décrit comment le contrat exigeait la suppression d’un grand mappage afin de retirer l’ether. La suppression de cette cartographie avait un coût en gaz qui dépassait la limite de gaz du bloc à l’époque, et il n’était donc pas possible de retirer les 1 100 ethers . L’adresse du contrat est 0xF45717552f12Ef7cb65e95476F217Ea008167Ae3, et vous pouvez voir dans la transaction 0x0d80d67202bd9cb6773df8dd2020e719 0a1b0793e8ec4fc105257e8128f0506b que les 1 100 ether ont finalement été obtenus via 2.5M en frais de gaz (et ce quand la limite du gaz fût augmenté).

Blocage de la manipulation de l’horodatage

Les horodatages de bloc ont historiquement été utilisés pour une variété d’applications, telles que l’entropie pour les nombres aléatoires (voir l'Illusion d’entropie pour plus de détails), le verrouillage des fonds pendant des périodes de temps et diverses instructions conditionnelles de changement d’état qui dépendent du temps. Les mineurs ont la possibilité d' ajuster légèrement les horodatages, ce qui peut s’avérer dangereux si les horodatages de bloc sont utilisés de manière incorrecte dans les contrats intelligents.

Les références utiles pour cela incluent la documentation Solidity et la question Ethereum sur Stack Exchange de Joris Bontje sur le sujet.

La vulnérabilité

block.timestamp et son alias now peuvent désormais être manipulés par les mineurs s’ils sont incités à le faire. Construisons un jeu simple, montré dans roulette.sol, qui serait vulnérable à l’exploitation des mineurs.

Example 11. roulette.sol
contract Roulette {
    uint public pastBlockTime; // force un pari par bloc

    constructor() external payable {} // initialise le financement du contrat

    // fonction de repli utilisée pour faire un pari
    function () external payable {
        require(msg.value == 10 ether); // doit envoyer 10 ether pour jouer
        require(now != pastBlockTime); // seulement 1 transaction par bloc
        pastBlockTime = now;
        if(now % 15 == 0) { // gagnant
            msg.sender.transfer(this.balance);
        }
    }
}

Ce contrat se comporte comme une simple loterie. Une transaction par bloc peut parier 10 ethers pour avoir une chance de gagner le solde du contrat. L’hypothèse ici est que les deux derniers chiffres de block.timestamp sont uniformément distribués. Si tel était le cas, il y aurait 1 chance sur 15 de gagner à cette loterie.

Cependant, comme nous le savons, les mineurs peuvent ajuster l’horodatage s’ils en ont besoin. Dans ce cas particulier, s’il y a suffisamment d’ether dans le contrat, un mineur qui résout un bloc est incité à choisir un horodatage tel que block.timestamp ou now modulo 15 est 0. Ce faisant, ils peuvent gagner l’ether verrouillé dans ce contrat avec la récompense globale. Comme il n’y a qu’une seule personne autorisée à parier par bloc, cela est également vulnérable aux attaques de dépassement (voir Conditions de course/devancement pour plus de détails).

En pratique, les horodatages de bloc augmentent de manière monotone et les mineurs ne peuvent donc pas choisir des horodatages de bloc arbitraires (ils doivent être postérieurs à leurs prédécesseurs). Ils sont également limités à définir des heures de bloc pas trop éloignées dans le futur, car ces blocs seront probablement rejetés par le réseau (les nœuds ne valideront pas les blocs dont les horodatages sont dans le futur).

Techniques préventives

Les horodatages de bloc ne doivent pas être utilisés pour l’entropie ou la génération de nombres aléatoires, c’est-à-dire qu’ils ne doivent pas être le facteur décisif (directement ou par dérivation) pour gagner un jeu ou changer un état important.

Une logique sensible au temps est parfois nécessaire ; par exemple, pour débloquer des contrats (verrou temporel), remplir une ICO après quelques semaines ou faire respecter des dates d’expiration. Il est parfois recommandé d’utiliser block.number et un temps de bloc moyen pour estimer les temps; avec un temps de bloc de 10 secondes, 1 semaine équivaut à environ 60 480 blocs. Ainsi, spécifier un numéro de bloc auquel changer un état de contrat peut être plus sûr, car les mineurs sont incapables de manipuler facilement le numéro de bloc. Le contrat BAT ICO a employé cette stratégie.

Cela peut être inutile si les contrats ne sont pas particulièrement concernés par les manipulations des mineurs de l’horodatage du bloc, mais c’est quelque chose dont il faut être conscient lors de l’élaboration des contrats.

Exemple concret : GovernMental

GovernMental, l’ancien schéma de Ponzi mentionné ci-dessus, était également vulnérable à une attaque basée sur l’horodatage. Le contrat est payé au joueur qui a été le dernier joueur à rejoindre (pendant au moins une minute) un tour. Ainsi, un mineur qui était un joueur pouvait ajuster l’horodatage (à une heure future, pour donner l’impression qu’une minute s’était écoulée) pour faire apparaître qu’il était le dernier joueur à rejoindre pendant plus d’une minute (même si ce n’était pas vrai dans la réalité). Plus de détails à ce sujet peuvent être trouvés dans le post "Historique des vulnérabilités de sécurité d’Ethereum, des piratages et de leurs correctifs" par Tanya Bahrynovska.

Constructeurs avec soin

Les constructeurs sont des fonctions spéciales qui effectuent souvent des tâches critiques et privilégiées lors de l’initialisation des contrats. Avant Solidity v0.4.22, les constructeurs étaient définis comme des fonctions portant le même nom que le contrat qui les contenait. Dans de tels cas, lorsque le nom du contrat est modifié au cours du développement, si le nom du constructeur n’est pas également modifié, il devient une fonction appelable normale. Comme vous pouvez l’imaginer, cela peut conduire (et a conduit) à des hacks de contrats intéressants.

Pour plus d’informations, le lecteur peut être intéressé à tenter les défis Ethernaut (en particulier le niveau Fallout).

La vulnérabilité

Si le nom du contrat est modifié, ou s’il y a une faute de frappe dans le nom du constructeur qui ne correspond pas au nom du contrat, le constructeur se comportera comme une fonction normale. Cela peut avoir des conséquences désastreuses, surtout si le constructeur effectue des opérations privilégiées. Considérez le contrat suivant :

contract OwnerWallet {
    address public owner;
    // constructeur
    function ownerWallet(address _owner) public {
        owner = _owner;
    }

    // Par défaut. Récupérer de l'ether.
    function () payable {}

    function withdraw() public {
        require(msg.sender == owner);
        msg.sender.transfer(this.balance);
    }
}

Ce contrat collecte de l’ether et permet uniquement au propriétaire de le retirer, en appelant la fonction de retrait withdraw. Le problème se pose car le constructeur ne porte pas exactement le même nom que le contrat : la première lettre est différente ! Ainsi, n’importe quel utilisateur peut appeler la fonction ownerWallet, se définir comme propriétaire, puis prendre tout l’ether du contrat en appelant withdraw.

Techniques préventives

Ce problème a été résolu dans la version 0.4.22 du compilateur Solidity. Cette version a introduit un mot clé constructor qui spécifie le constructeur, plutôt que d’exiger que le nom de la fonction corresponde au nom du contrat. L’utilisation de ce mot-clé pour spécifier les constructeurs est recommandée pour éviter les problèmes de nommage.

Exemple concret : Rubixi

Rubixi était un autre système pyramidal qui présentait ce type de vulnérabilité. Il s’appelait à l’origine DynamicPyramid , mais le nom du contrat a été modifié avant le déploiement sur Rubixi. Le nom du constructeur n’a pas été modifié, permettant à n’importe quel utilisateur de devenir le créateur. Des discussions intéressantes liées à ce bogue peuvent être trouvées sur Bitcointalk. En fin de compte, cela a permis aux utilisateurs de se battre pour le statut de créateur afin de réclamer les frais du système pyramidal. Plus de détails sur ce bogue particulier peuvent être trouvés dans "Historique des vulnérabilités de sécurité d’Ethereum, des piratages et de leurs correctifs".

Pointeurs de stockage non initialisés

L’EVM stocke les données sous forme de stockage (disque, par exemple) ou de mémoire. Il est fortement recommandé de comprendre exactement comment cela se fait et les types par défaut des variables locales des fonctions lors du développement de contrats. En effet, il est possible de produire des contrats vulnérables en initialisant de manière inappropriée des variables.

Pour en savoir plus sur le stockage et la mémoire dans l’EVM, consultez la documentation de Solidity sur l’emplacement des données , la disposition des variables d’état dans le stockage et la disposition dans la mémoire.

Note

Cette section est basée sur un excellent article de Stefan Beyer. D’autres lectures sur ce sujet, inspirées par Stefan, peuvent être trouvées dans ce fil Reddit.

La vulnérabilité

Les variables locales dans les fonctions sont par défaut le stockage ou la mémoire en fonction de leur type. Les variables de stockage local non initialisées peuvent contenir la valeur d’autres variables de stockage dans le contrat ; ce fait peut provoquer des vulnérabilités involontaires ou être exploité délibérément.

Considérons le contrat de bureau d’enregistrement de noms relativement simple dans NameRegistrar.sol.

Example 12. NameRegistrar.sol
// Un bureau d'enregistrement de noms verrouillé
contract NameRegistrar {

    bool public unlocked = false;  // bureau d'enregistrement verrouillé, pas de mises à jour de nom

    struct NameRecord { // mappe les hachages aux adresses
        bytes32 name;
        address mappedAddress;
    }

    // enregistre les noms
    mapping(address => NameRecord) public registeredNameRecord;
    // résout les hachages en adresses
    mapping(bytes32 => address) public resolve;

    function register(bytes32 _name, address _mappedAddress) public {
        // configure le nouveau NameRecord
        NameRecord newRecord;
        newRecord.name = _name;
        newRecord.mappedAddress = _mappedAddress;

        resolve[_name] = _mappedAddress;
        registeredNameRecord[msg.sender] = newRecord;

        require(unlocked); // n'autorise les inscriptions que si le contrat est déverrouillé
    }
}

Ce simple registraire de noms n’a qu’une seule fonction. Lorsque le contrat est unlocked (déverrouillé), il permet à quiconque d’enregistrer un nom (sous forme de hachage bytes32) et de mapper ce nom à une adresse. Le bureau d’enregistrement est initialement verrouillé et l' exigence require à la ligne 25 empêche le registre d’ajouter des enregistrements de nom avec register. Il semble que le contrat soit inutilisable, car il n’y a aucun moyen de déverrouiller le registre ! Il existe cependant une vulnérabilité qui permet l’enregistrement du nom quelle que soit la variable unlocked.

Pour discuter de cette vulnérabilité, nous devons d’abord comprendre comment fonctionne le stockage dans Solidity. En tant qu’aperçu de haut niveau (sans aucun détail technique approprié - nous vous suggérons de lire les documents Solidity pour un examen approprié), les variables d’état sont stockées séquentiellement dans les emplacements (ou slots) tels qu’ils apparaissent dans le contrat (ils peuvent être regroupés mais ne sont pas dans ce exemple, donc nous ne nous en préoccuperons pas). Ainsi, unlocked existe dans slot[0], registerNameRecord dans slot[1] , et resolve dans slot[2], etc. Chacun de ces emplacements a une taille de 32 octets (il y a des complexités supplémentaires avec les mappages, que nous ignorerons pour à présent). Le booléen unlocked ressemblera à 0x000…​0 (64 0s, à l’exclusion du 0x) pour false ou 0x000…​1 (63 0s) pour true. Comme vous pouvez le voir, il y a un important gaspillage de stockage dans cet exemple particulier .

La pièce suivante du puzzle est que Solidity place par défaut des types de données complexes, tels que des structures structs, en stockage lors de leur initialisation en tant que variables locales. Par conséquent, newRecord à la ligne 18 utilise par défaut le stockage. La vulnérabilité est causée par le fait que newRecord n’est pas initialisé. Comme il s’agit par défaut du stockage, il est mappé sur l’emplacement de stockage slot[0], qui contient actuellement un pointeur vers unlocked. Notez qu’aux lignes 19 et 20, nous définissons ensuite newRecord.name sur _name et newRecord.mappedAddress sur _mappedAddress; cela met à jour les emplacements de stockage de slot[0] et slot[1], ce qui modifie à la fois unlocked et l’emplacement de stockage associé à registerNameRecord.

Cela signifie que unlocked peut être modifié directement, simplement par le paramètre bytes32 _name de la fonction register. Par conséquent, si le dernier octet de _name est différent de zéro, il modifiera le dernier octet de storage slot[0] et changera directement unlocked en true . De telles valeurs _name entraîneront la réussite de l’appel require sur la ligne 25, car nous avons défini unlocked sur true . Essayez ceci dans Remix. Notez que la fonction passera si vous utilisez un _name du formulaire :

0x0000000000000000000000000000000000000000000000000000000000000001

Techniques préventives

Solidity affiche un avertissement pour les variables de stockage non initialisées ; les développeurs doivent prêter une attention particulière à ces avertissements lors de la création de contrats intelligents. La version actuelle de Mist (0.10) ne permet pas de compiler ces contrats. Il est souvent recommandé d’utiliser explicitement les spécificateurs de mémoire memory ou de stockage storage lorsqu’il s’agit de types complexes, pour s’assurer qu’ils se comportent comme prévu.

Exemples concrets : Pots de miel OpenAddressLottery et CryptoRoulette

Un pot de miel nommé OpenAddressLotterya été déployé qui a utilisé cette bizarrerie de variable de stockage non initialisée pour collecter de l’ether auprès de certains pirates potentiels. Le contrat est plutôt impliqué, nous laisserons donc l’analyse au fil Reddit où l’attaque est assez clairement expliquée.

Un autre pot de miel, CryptoRoulette, a également utilisé cette astuce pour essayer de collecter de l’ether. Si vous ne pouvez pas comprendre comment fonctionne l’attaque, consultez « Une analyse de quelques contrats de pot de miel Ethereum » pour un aperçu de ce contrat et d’autres.

Virgule flottante et de précision

Au moment d’écrire ces lignes (v0.4.24), Solidity ne prend pas en charge les nombres à virgule fixe et à virgule flottante. Cela signifie que les représentations en virgule flottante doivent être construites avec des types entiers dans Solidity. Cela peut entraîner des erreurs et des vulnérabilités s’il n’est pas mis en œuvre correctement.

Note

Pour en savoir plus, consultez le wiki Ethereum Contract Security Techniques and Tips.

La vulnérabilité

Comme il n’y a pas de type à virgule fixe dans Solidity, les développeurs doivent implémenter le leur en utilisant les types de données entiers standard. Il existe un certain nombre de pièges que les développeurs peuvent rencontrer au cours de ce processus. Nous allons essayer d’en souligner quelques-uns dans cette section.

Commençons par un exemple de code (nous ignorerons les problèmes de soupassement/dépassement, abordés plus haut dans ce chapitre, pour plus de simplicité) :

contract FunWithNumbers {
    uint constant public tokensPerEth = 10;
    uint constant public weiPerEth = 1e18;
    mapping(address => uint) public balances;

    function buyTokens() external payable {
        // convertit wei en eth, puis multiplie par le taux de jeton
        uint tokens = msg.value/weiPerEth*tokensPerEth;
        balances[msg.sender] += tokens;
    }

    function sellTokens(uint tokens) public {
        require(balances[msg.sender] >= tokens);
        uint eth = tokens/tokensPerEth;
        balances[msg.sender] -= tokens;
        msg.sender.transfer(eth*weiPerEth);
    }
}

Ce simple contrat d’achat/vente de jetons présente des problèmes évidents. Bien que les calculs mathématiques pour l’achat et la vente de jetons soient corrects, l’absence de nombres à virgule flottante donnera des résultats erronés. Par exemple, lors de l’achat de jetons sur la ligne 8, si la valeur est inférieure à 1 ether, la division initiale donnera 0, laissant le résultat de la multiplication finale à 0 (par exemple, 200 wei divisé par 1e18 weiPerEth est égal à 0). De même, lors de la vente de jetons, tout nombre de jetons inférieur à 10 entraînera également 0 ether. En fait, l’arrondi ici est toujours inférieur, donc la vente de 29 jetons se traduira par 2 ether.

Le problème avec ce contrat est que la précision n’est qu’à l’ether le plus proche (c’est-à-dire 1e18 wei ). Cela peut devenir délicat lorsqu’il s’agit de décimales dans les jetons ERC20 lorsque vous avez besoin d’une plus grande précision.

Techniques préventives

Garder la bonne précision dans vos contrats intelligents est très important, en particulier lorsqu’il s’agit de ratios et de taux qui reflètent des décisions économiques.

Vous devez vous assurer que tous les ratios ou taux que vous utilisez permettent de grands numérateurs dans les fractions. Par exemple, nous avons utilisé le taux tokensPerEth dans notre exemple. Il aurait été préférable d’utiliser weiPerTokens, qui serait un grand nombre. Pour calculer le nombre correspondant de jetons, nous pourrions faire msg.value/weiPerTokens. Cela donnerait un résultat plus précis.

Une autre tactique à garder à l’esprit est l’ordre des opérations. Dans notre exemple, le calcul pour acheter des jetons était msg.value/weiPerEth*tokenPerEth. Notez que la division se produit avant la multiplication. (Solidity, contrairement à certains langages, garantit d’effectuer les opérations dans l’ordre dans lequel elles sont écrites.) Cet exemple aurait atteint une plus grande précision si le calcul effectuait d’abord la multiplication puis la division; c’est-à-dire msg.value*tokenPerEth/weiPerEth.

Enfin, lors de la définition d’une précision arbitraire pour les nombres, il peut être judicieux de convertir les valeurs en une précision supérieure, d’effectuer toutes les opérations mathématiques, puis de les reconvertir finalement à la précision requise pour la sortie. Généralement, les uint256 sont utilisés (car ils sont optimaux pour l’utilisation du gaz) ; ceux-ci donnent environ 60 ordres de grandeur dans leur gamme, dont certains peuvent être dédiés à la précision des opérations mathématiques. Il se peut qu’il soit préférable de conserver toutes les variables en haute précision dans Solidity et de les reconvertir en précisions inférieures dans les applications externes (c’est essentiellement ainsi que fonctionne la variable décimale dans les contrats de jeton ERC20). Pour voir un exemple de la façon dont cela peut être fait, nous vous recommandons de regarder DS-Math. Il utilise des noms funky ("wads" et "rays"), mais le concept est utile.

Exemple concret : Ethstick

Le contrat Ethstickn’utilise pas la précision étendue; cependant, il traite de wei. Donc, ce contrat aura des problèmes d’arrondi, mais seulement au niveau de précision wei. Il a quelques défauts plus graves, mais ceux-ci sont liés à la difficulté d’obtenir de l’entropie sur la chaîne de blocs (voir Illusion d’entropie). Pour une discussion plus approfondie du contrat Ethstick, nous vous renverrons à un autre article de Peter Vessenes, "Les contrats Ethereum vont être des bonbons pour les pirates".

Authentification Tx.Origin

Solidity a une variable globale, tx.origin, qui parcourt toute la pile d’appels et contient l’adresse du compte qui a initialement envoyé l’appel (ou la transaction). L’utilisation de cette variable pour l’authentification dans un contrat intelligent rend le contrat vulnérable à une attaque de type hameçonnage.

Note

Pour en savoir plus, consultez la question Ethereum sur Stack Exchange de dbryson , "Tx.Origin and Ethereum Oh My!" de Peter Vessenes et "Solidity : Tx Origin Attacks" de Chris Coverdale.

La vulnérabilité

Les contrats qui autorisent les utilisateurs à utiliser la variable tx.origin sont généralement vulnérables aux attaques de phishing qui peuvent inciter les utilisateurs à effectuer des actions authentifiées sur le contrat vulnérable. Considérez le contrat simple dans Phishable.sol.

Example 13. Phishable.sol
contract Phishable {
    address public owner;

    constructor (address _owner) {
        owner = _owner;
    }

    function () external payable {} // recevoir ether

    function withdrawAll(address _recipient) public {
        require(tx.origin == owner);
        _recipient.transfer(this.balance);
    }
}

Notez qu’à la ligne 11 le contrat autorise la fonction withdrawAll en utilisant tx.origin. Ce contrat permet à un attaquant de créer un contrat d’attaque de la forme :

import "Phishable.sol";

contract AttackContract {

    Phishable phishableContract;
    address attacker; // L'adresse de l'attaquant pour recevoir les fonds

    constructor (Phishable _phishableContract, address _attackerAddress) {
        phishableContract = _phishableContract;
        attacker = _attackerAddress;
    }

    function () payable {
        phishableContract.withdrawAll(attacker);
    }
}

L’attaquant pourrait déguiser ce contrat en sa propre adresse privée et organiser socialement la victime (le propriétaire du contrat Phishable ) pour envoyer une forme de transaction à l’adresse, peut-être en envoyant ce contrat une certaine quantité d’ether. La victime, à moins d’être prudente, peut ne pas remarquer qu’il y a du code à l’adresse de l’attaquant, ou l’attaquant peut le faire passer pour un portefeuille multisignature ou un portefeuille de stockage avancé (rappelez-vous que le code source des contrats publics n’est pas disponible par défaut) .

Dans tous les cas, si la victime envoie une transaction avec suffisamment de gaz à l' adresse AttackContract, elle invoquera la fonction de secours, qui à son tour appellera la fonction withdrawAll du contrat Phishable avec le paramètre attacker. Cela entraînera le retrait de tous les fonds du contrat Phishable à l’adresse de l' attaquant attacker. C’est parce que l’adresse qui a initialement initialisé l’appel était la victime (c’est-à-dire le propriétaire du contrat Phishable). Par conséquent, tx.origin sera égal à owner et l' exigence require à la ligne 11 du contrat Phishable passera.

Techniques préventives

tx.origin ne doit pas être utilisé pour l’autorisation dans les contrats intelligents. Cela ne veut pas dire que la variable tx.origin ne doit jamais être utilisée. Il existe des cas d’utilisation légitimes dans les contrats intelligents. Par exemple, si l’on voulait empêcher les contrats externes d’appeler le contrat actuel, on pourrait implémenter un require de la forme require(tx.origin==msg.sender). Cela empêche l’utilisation de contrats intermédiaires pour appeler le contrat en cours, limitant le contrat à des adresses sans code régulier.

Contrats de bibliothèques

De nombreux codes existants sont disponibles pour être réutilisés, à la fois déployés en chaîne en tant que bibliothèques appelables et hors chaîne en tant que bibliothèques de modèles de code. Les bibliothèques sur plate-forme, ayant été déployées, existent sous forme de contrats intelligents en code intermédiaire (bytecode), il faut donc faire très attention avant de les utiliser en production. Cependant, l’utilisation de bibliothèques sur plate-forme existantes bien établies présente de nombreux avantages, tels que la possibilité de bénéficier des dernières mises à jour, et vous permet d’économiser de l’argent et profite à l’écosystème Ethereum en réduisant le nombre total de contrats en direct dans Ethereum.

Dans Ethereum, la ressource la plus largement utilisée est la suite OpenZeppelin, une vaste bibliothèque de contrats allant des implémentations de jetons ERC20 et ERC721, à de nombreuses variantes de modèles de crowdsale , en passant par des comportements simples couramment trouvés dans les contrats, tels que Ownable, Pausable ou LimitBalance. Les contrats de ce référentiel ont été largement testés et, dans certains cas, fonctionnent même comme des implémentations standard de facto . Ils sont libres d' utilisation et sont construits et maintenus par Zeppelin avec une liste sans cesse croissante de contributeurs externes.

Également de Zeppelin est ZeppelinOS, une plate-forme à source libre de services et d’outils pour développer et gérer en toute sécurité des applications de contrat intelligent. ZeppelinOS fournit une couche au-dessus de l’EVM qui permet aux développeurs de lancer facilement des DApps évolutives liées à une bibliothèque en chaîne de contrats bien testés qui sont eux-mêmes évolutifs. Différentes versions de ces bibliothèques peuvent coexister sur la plate-forme Ethereum, et un système de caution permet aux utilisateurs de proposer ou de pousser des améliorations dans différentes directions. Un ensemble d’outils hors chaîne pour déboguer, tester, déployer et surveiller les applications décentralisées est également fourni par la plate-forme.

Le projet ethpm vise à organiser les différentes ressources qui se développent dans l’écosystème en proposant un système de gestion de packages. En tant que tel, leur registre fournit plus d’exemples à parcourir :

Conclusion

Tout développeur travaillant dans le domaine des contrats intelligents a beaucoup à savoir et à comprendre. En suivant les meilleures pratiques dans la conception de votre contrat intelligent et la rédaction de votre code, vous éviterez de nombreux pièges et attrappes.

Le principe de sécurité logicielle le plus fondamental est peut-être de maximiser la réutilisation du code de confiance. En cryptographie, c’est si important qu’il a été condensé en un adage : "Ne lancez pas votre propre crypto." Dans le cas des contrats intelligents, cela revient à tirer le meilleur parti possible des bibliothèques librement disponibles qui ont été soigneusement contrôlées par la communauté.