La sécurité des smart contracts Ethereum

Traduction de Onward with Ethereum Smart Contract Security de Manuel Aráoz par Vincent Le Gallic

Si vous débutez en développement sur Ethereum, je vous recommande cette introduction aux Smart Contracts avec Ethereum avant de commencer.

Monter en compétence sur la sécurité des smart contracts n’est pas une chose facile. il existe quelques bons guides comme par exemple “Consensys’ Smart Contracts Best Practices” ou “the Solidity Documentation Security Considerations” mais ces concepts restent très difficiles à appréhender sans écrire soi même du code.

Je vais donc tenter une approche légèrement différente. Je vais expliquer certaines recommandations qui améliorent la sécurité des Smart Contract. Je montrerai des extraits de codes qui ne suivent pas ces recommandations pour mettre en avant les failles. Je vais également vous donner des exemples de code que vous pouvez utiliser pour rendre vos Smart Contracts plus fiables en espérant que cela déclenche de bons réflexes lorsque vous coderez vos smart contracts “réels”.

Sans plus tarder, découvrons les bonnes pratiques:

Échouez dès que possible et échouez franchement

Fail as early and loudly as possible

Un moyen simple et efficace d’améliorer vos programmes est de suivre la règle du “Fail Early, Fail Loudly”. Voici un exemple de code qui ne “Fail” pas de façon assez évidente.

// UNSAFE CODE, DO NOT USE!
contract BadFailEarly {
  uint constant DEFAULT_SALARY = 50000;
  mapping(string => uint) nameToSalary;
function getSalary(string name) constant returns (uint) {
    if (bytes(name).length != 0 && nameToSalary[name] != 0) {
      return nameToSalary[name];
    } else {
      return DEFAULT_SALARY;
    }
  }
}

Nous voulons éviter qu’un contrat échoue silencieusement ou qu’il continue à être exécuté dans un état instable ou incohérent. La fonction getSalary vérifie les conditions avant de retourner le salaire stocké, ce qui est une bonne chose. Le problème c’est que dans le cas où ces conditions ne sont pas remplies, une valeur par défaut est renvoyée. Cela pourrait cacher une erreur de l’appelant. Il s’agit d’un cas extrême, mais ce genre de code est très fréquent et s’explique par la crainte de déclencher des erreurs qui stoppent notre application. En réalité, plus tôt nous échouerons, plus il sera facile de trouver le problème. Si nous cachons des erreurs, elles peuvent se propager vers d’autres parties du code et provoquer des incohérences difficiles à tracer. Une meilleure approche serait:

contract GoodFailEarly {
  mapping(string => uint) nameToSalary;
  
  function getSalary(string name) constant returns (uint) {
    if (bytes(name).length == 0) throw;    
    if (nameToSalary[name] == 0) throw;
    
    return nameToSalary[name];
  }
}

Cette version montre une autre manière de programmer plus adaptée qui sépare les conditions préalables et déclenche des exceptions séparément. Notez que certaines de ces vérifications (en particulier celles qui dépendent d’un état interne) peuvent être implémentées avec des Fonctions “Modifiers”.

Préférez les “pull paiements” au “push paiements”

Chaque transfert d’ether implique une exécution potentielle de code. L’adresse de réception peut implémenter une “Fallback” fonction qui peut lancer une erreur. Ainsi, nous ne devrions jamais considérer qu’un appel à send s’exécute sans erreur. Une solution: nos contrats devraient favoriser les “pull paiements”. Regardez ce code naïf d’enchère:

// UNSAFE CODE, DO NOT USE!
contract BadPushPayments {
  address highestBidder;
  uint highestBid;
 
  function bid() {
    if (msg.value < highestBid) throw;
    if (highestBidder != 0) {
      // return bid to previous winner
      if (!highestBidder.send(highestBid)) {
        throw;
      }
    }
    highestBidder = msg.sender;
    highestBid = msg.value;
  }
}

Notez que le contrat appelle la fonction send et vérifie la valeur de retour, ce qui semble correcte. Mais l’appel à send au milieu d’une fonction n’est pas sécurisé. Pourquoi? Rappelez-vous que, comme indiqué ci-dessus, send peut déclencher l’exécution de code dans un autre contrat.

Imaginez que quelqu’un enchérisse avec une adresse qui déclenche une erreur à chaque fois que quelqu’un envoie de l’argent dessus. Que se passe-t-il lorsque quelqu’un d’autre tente d’enchérir? L’appel de send échouera toujours et remontera jusqu’à la function bid qui échouera à son tour. Un appel de fonction qui se termine par une erreur laisse l’état inchangé (toutes les modifications apportées sont annulées). Cela signifie que personne d’autre ne peut enchérir, le contrat est rompu.

La solution la plus simple consiste à séparer les paiements dans une fonction différente qui permettra aux utilisateurs de demander (pull) des fonds indépendamment du reste de la logique du contrat:

contract GoodPullPayments {
  address highestBidder;
  uint highestBid;
  mapping(address => uint) refunds;
  
  function bid() external {
    if (msg.value < highestBid) throw;
    
    if (highestBidder != 0) {
      refunds[highestBidder] += highestBid;
    }
    
    highestBidder = msg.sender;
    highestBid = msg.value;
  }
  
  function withdrawBid() external {
    uint refund = refunds[msg.sender];
    refunds[msg.sender] = 0;
    if (!msg.sender.send(refund)) {
      refunds[msg.sender] = refund;
    }
  }
}

Cette fois, nous utilisons un mapping pour stocker les valeurs de remboursement pour les enchères dépassées et nous ajoutons une fonction permettant aux enchérisseurs de retirer leurs fonds.

En cas de problème dans l’appel de send, seul l’enchérisseur appelant est affecté. Il s’agit d’un modèle simple qui résout beaucoup d’autres problèmes (Notamment la faille de réentrance), alors n’oubliez pas: lors de l’envoi d’ether, favorisez les “pull” au “push” paiements.

J’ai mis en place un contrat dont vous pouvez hériter pour utiliser facilement ce pattern.

Organisez le code de vos fonctions: Conditions, actions, interactions

En complément du principe “Échouez dès que possible”, une bonne pratique consiste à structurer toutes vos fonctions comme suit: d’abord, vérifiez toutes les conditions préalables; ensuite, modifiez l’état de votre contrat; et enfin, interagissez avec d’autres contrats.

Conditions, actions, interactions. S’en tenir à cette structure de fonctions vous évitera beaucoup de problèmes. Voyons un exemple d’une fonction utilisant ce pattern:

function auctionEnd() {
  // 1. Conditions
  if (now <= auctionStart + biddingTime)
    throw; // auction did not yet end
  if (ended)
    throw; // this function has already been called

  // 2. Effects
  ended = true;
  AuctionEnded(highestBidder, highestBid);

  // 3. Interaction
  if (!beneficiary.send(highestBid))
    throw;
  }
}

Ce code est conforme au principe d’échouer vite , car les conditions sont vérifiées au début et les interactions potentiellement dangereuses avec d’autres contrats sont placées en dernière position.

Tenez compte des limites de la plateforme

L’EVM a beaucoup de limites strictes sur ce que nos contrats peuvent faire. Ce sont des considérations de sécurité au niveau de la plateforme, mais elles peuvent menacer la sécurité de votre contrat si vous les ignorez. Jetons un coup d’oeil à ce code naïf de gestion des bonus des employés.

// UNSAFE CODE, DO NOT USE!
contract BadArrayUse {
  
  address[] employees;
  
  function payBonus() {
    for (var i = 0; i < employees.length; i++) {
      address employee = employees[i];
      uint bonus = calculateBonus(employee);
      employee.send(bonus);
    }     
  }
  
  function calculateBonus(address employee) returns (uint) {
    // some expensive computation ...
  }
}

Lisez ce code: c’est assez simple et ça semble correct… Ce code cache pourtant 3 problèmes potentiels liés aux limites de la plateforme.

Le premier problème est le type de i, uint8 sera appliqué car c’est le plus petit type qui supporte la valeur 0. Si le tableau a plus de 255 éléments, la boucle ne se terminera pas, ce qui entraînera une épuisement du gas. Utiliser le type uint explicitement est plus judicieux car cela évitera les surprises et augmentera la limite. Evitez de déclarer les variables en utilisant var quand cela est possible. Maintenant, réparons ça:

// STILL UNSAFE CODE, DO NOT USE!
contract BadArrayUse {
  
  address[] employees;
  
  function payBonus() {
    for (uint i = 0; i < employees.length; i++) {
      address employee = employees[i];
      uint bonus = calculateBonus(employee);
      employee.send(bonus);
    }     
  }
  
  function calculateBonus(address employee) returns (uint) {
    // some expensive computation ...
  }
}

La deuxième aspect que vous devez considérer est la limite de gas. Le gas est le mécanisme d’Ethereum pour payer les ressources du réseau. Chaque appel de fonction qui modifie l’état a un coût en gas. Imaginez que CalculBonus détermine le bonus pour chaque employé en fonction d’un calcul complexe, comme le calcul du profit sur de nombreux projets par exemple. Cela dépenserait beaucoup de gas, ce qui pourrait facilement atteindre la limite de gas de la transaction ou du bloc. Si une transaction atteint la limite de gas, toutes les modifications sont annulées, mais les frais eux restent à payer. Tenez compte des coûts variables de gas lors de l’utilisation de boucles.

Optimisons le contrat en séparant le calcul du bonus de la boucle for. Notez que cela ne résout pas le problème: à mesure que le nombre de salariés augmente, le coût du gas augmente.

// UNSAFE CODE, DO NOT USE!
contract BadArrayUse {
  
  address[] employees;
  mapping(address => uint) bonuses;  
  
  function payBonus() {
    for (uint i = 0; i < employees.length; i++) {
      address employee = employees[i];
      uint bonus = bonuses[employee];
      employee.send(bonus);
    }     
  }
  
  function calculateBonus(address employee) returns (uint) {
    uint bonus = 0;
    // some expensive computation modifying the bonus...
    bonuses[employee] = bonus;
  }
}

Enfin, la profondeur de la pile d’appels est limitée. La pile d’appels de l’EVM a une limite stricte de 1024. Cela signifie que si le nombre d’appels récursifs atteint 1024, le contrat échoue. Un attaquant peut appeler un contrat récursivement 1023 fois puis appeler une fonction de notre contrat, cela provoquera une erreur silencieuse en raison de cette limite. PullPaymentCapable.sol présenté plus haut permet d’implémenter facilement des “pull paiements”. Hériter de PullPaymentCapable et utiliser asyncSend vous protègent.

Voici une version modifiée du code qui corrige tous ces problèmes:

import './PullPaymentCapable.sol';
contract GoodArrayUse is PullPaymentCapable {
  address[] employees;
  mapping(address => uint) bonuses;
  
  function payBonus() {
    for (uint i = 0; i < employees.length; i++) {
      address employee = employees[i];
      uint bonus = bonuses[employee];
      asyncSend(employee, bonus);
    }
  }
function calculateBonus(address employee) returns (uint) {
    uint bonus = 0;
    // some expensive computation...
    bonuses[employee] = bonus;
  }
}

En résumé, tenez compte (1) des limites dans les types de variables que vous utilisez, (2) des limites de coûts de gas de votre contrat et (3) de la limite de profondeur de la pile d’appels (stack depth limit).

Écrivez des tests

Ecrire des tests représente beaucoup de travail, mais cela vous évitera des problèmes de régression. Un bug de régression apparaît lorsqu’un composant stable devient instable à cause d’une modification récente. J’écrirai prochainement un guide sur ce sujet, mais si vous êtes intéressés, consultez le “Truffle’s testing guide”.

Tolérance aux pannes et bug bounties automatiques

(Un bug bounty est un programme qui permet à des personnes de recevoir une compensation après avoir reporté des bugs ou des vulnérabilités)

Merci à Peter Borah pour l’inspiration de ces deux idées. Les relectures de code et les audits de sécurité ne suffisent pas rendre les smart contracts infaillibles. Notre code doit être paré au pire. Dans le cas d’une vulnérabilité dans notre Smart contract, il devrait y avoir un moyen sûr de le réparer. En plus de cela, nous devrions essayer de découvrir ces vulnérabilités le plus tôt possible. C’est là que les bug bounties automatiquement intégrés dans notre contrat peuvent aider.

Examinons une implémentation de bug bounty automatique dans un exemple (simplifié) de contrat Token

import './PullPaymentCapable.sol';
import './Token.sol';
contract Bounty is PullPaymentCapable {
  bool public claimed;
  mapping(address => address) public researchers;
  
  function() {
    if (claimed) throw;
  }
  
  function createTarget() returns(Token) {
    Token target = new Token(0);
    researchers[target] = msg.sender;
    return target;
  }
  
  function claim(Token target) {
    address researcher = researchers[target];
    if (researcher == 0) throw;
    
    // check Token contract invariants
    if (target.totalSupply() == target.balance) {
      throw;
    }
    asyncSend(researcher, this.balance);
    claimed = true;
  }
}

Comme précédemment, nous utilisons PullPaymentCapable pour sécuriser nos paiements sortants. Ce contrat Bounty permet aux chercheurs de créer des copies du contrat Token que nous souhaitons auditer. Tout le monde peut contribuer en envoyant des transactions à l’adresse du contrat Bounty. Si un chercheur parvient à corrompre sa copie du contrat Token, en faisant une faille immuable (par exemple en rendant le nombre de Token distribués différent de la balance), il obtiendra la totalité des compensations. Une fois la faille revendiquée, le contrat n’acceptera plus de fonds (cette fonction sans nom est appelée “fallback function”, elle est exécutée à chaque fois que de l’argent est envoyé directement au contrat).

Comme vous pouvez le voir, il s’agit d’un contrat distinct et cela ne nécessite aucune modification de notre contrat Token d’origine. Voici une implémentation complète disponible sur GitHub que tout le monde peut utiliser.

En ce qui concerne la tolérance aux pannes, nous allons devoir modifier notre contrat d’origine pour ajouter des mécanismes de sécurité supplémentaires. Une idée simple est de permettre au gardien d’un contrat de geler le contrat en cas d’urgence. Voyons une façon de mettre en œuvre ce comportement avec de l’héritage:

contract Stoppable {
  address public curator;
  bool public stopped;
modifier stopInEmergency { if (!stopped) _ }
  modifier onlyInEmergency { if (stopped) _ }
  
  function Stoppable(address _curator) {
    if (_curator == 0) throw;
    curator = _curator;
  }
  
  function emergencyStop() external {
    if (msg.sender != curator) throw;
    stopped = true;
  }
}

Stoppable permet de spécifier l’adresse d’un gardien (curator) qui pourra arrêter le contrat. Que signifie «arrêter le contrat»? Cela doit être défini par le contrat enfant qui hérite de Stoppable en utilisant les fonctions modifiers stopInEmergency et onlyInEmergency. Voyons un exemple:

import './PullPaymentCapable.sol';
import './Stoppable.sol';
contract StoppableBid is Stoppable, PullPaymentCapable {
  address public highestBidder;
  uint public highestBid;
  
  function StoppableBid(address _curator)
    Stoppable(_curator)
    PullPaymentCapable() {}
  
  function bid() external stopInEmergency {
    if (msg.value <= highestBid) throw;
    
    if (highestBidder != 0) {
      asyncSend(highestBidder, highestBid);
    }
    highestBidder = msg.sender;
    highestBid = msg.value;
  }
  
  function withdraw() onlyInEmergency {
    suicide(curator);
  }
}

Dans cet exemple d’enchères, le système peut maintenant être interrompu par un gardien (curator), défini lors de la création du contrat. Tant que StoppableBid est en mode normal, seule la fonction bid peut être appelée. Si quelque chose d’étrange se produit et que le contrat est dans un état incohérent, le gardien peut intervenir et activer l’état d’urgence (emergency mode). Cela désactive la fonction dib et permet à la fonction withdraw de fonctionner.

Dans cet exemple, l’état d’urgence (emergency mode) ne permet qu’au conservateur de détruire le contrat et de récupérer les fonds, mais en réalité, la logique de récupération pourrait être plus complexe (par exemple, renvoyer des fonds à leurs propriétaires).

Limitez le montant des fonds déposés

Une autre façon de protéger nos smart contracts contre les attaques est de limiter leur portée. Les attaquants vont probablement cibler des contrats importants qui gèrent des millions de dollars. Tous les smart contracts ne doivent pas avoir des enjeux aussi importants, surtout si nous effectuons des tests. Dans ce contexte, il pourrait être utile de limiter le montant des fonds que notre contrat accepte. C’est aussi simple qu’une limite en dur sur la balance (le solde) de l’adresse du contrat.

Voici un exemple simplifié sur la façon de procéder:

contract LimitFunds {
  
  uint LIMIT = 5000;
  
  function() { throw; }
  
  function deposit() {
    if (this.balance > LIMIT) throw;
    ...
  }
}

La fallback function rejettera tout paiement direct au contrat. La fonction deposit vérifiera d’abord si la limite souhaitée n’est pas déjà dépassée avant d’autoriser un nouveau dépôt. Des choses plus intéressantes comme des limites dynamiques ou paramétrées sont faciles à mettre en œuvre.

Ecrivez un code simple et modulaire

Un code est fiable (au sens sécurisé) lorsque correspondent notre intention et ce que notre code permet réellement de faire. C’est très difficile à vérifier, surtout si le code est énorme et désordonné. C’est pourquoi il est important d’écrire un code simple et modulaire.

Cela signifie que les fonctions devraient être aussi courtes que possible, les dépendances de code devraient être réduites au minimum, et les fichiers devraient être aussi courts que possible, séparant la logique indépendante en modules, chacun avec une seule responsabilité.

Le nommage est également l’un des meilleurs moyens d’exprimer notre intention lors de l’écriture du code. Réfléchissez bien aux noms que vous choisissez, afin de rendre votre code aussi clair que possible.

Etudions un exemple de mauvais nommage d’événements. Regardez cette fonction à partir de The DAO. Je ne vais pas copier le code de la fonction ici car il est très long.

Le problème le plus important est que c’est trop long et trop complexe. Essayez de garder vos fonctions beaucoup plus courtes, disons 30 ou 40 lignes de code max. Idéalement, vous devriez pouvoir lire les fonctions et comprendre ce qu’elles font en moins d’une minute. Un autre problème est le mauvais nom pour l’événement Transfer. Le nom diffère d’une fonction appelée transfer par seulement 1 caractère! Cela apporte beaucoup de confusion. En général, le nommage recommandé pour les événements est de préfixer la variable par “Log”. Dans ce cas, un meilleur nom serait LogTransfer.

N’oubliez pas, écrivez des contrats simples, modulaires et aussi bien nommés que possible. Cela facilitera grandement les autres et vous-même pour auditer votre code.

N’écrivez pas tout votre code à partir de zéro

Enfin, comme le dit l’adage: “Don’t roll your own crypto”. Je pense que cela s’applique également au code des Smart Contracts. Vous manipulez de l’argent, votre code et vos données sont publics, et vous utilisez une plateforme récente et expérimentale. Les enjeux sont élevés et les occasions de tout gâcher sont partout.

Ces pratiques aident à sécuriser nos smart contracts. Mais, finalement, nous devrions créer de meilleurs outils de développement pour construire des smart contracts. Il existe des initiatives intéressantes comme ce meilleur système de type, Serenity Abstractions, et the Rootstock platform.

Il y a beaucoup de code sûr et de qualité déjà écrit et les frameworks commencent à apparaître. Nous avons commencé à compiler certaines des meilleures pratiques dans ce repo GitHub que nous avons appelé OpenZeppelin. N’hésitez pas à regarder et à contribuer avec du nouveau code ou des audits de sécurité.

Récapitulons !

  1. Échouez dès que possible et échouez franchement.
  2. Préférez les “pull paiements” au “push paiements”
  3. Organisez le code de vos fonctions: Conditions, actions, interactions
  4. Tenez compte des limites de la plate-forme
  5. Écrivez des tests
  6. Tolérance aux pannes et bug bounties automatiques
  7. Limitez le montant des fonds déposés
  8. Ecrivez un code simple et modulaire
  9. N’écrivez pas tout votre code à partir de zéro

Si vous souhaitez vous joindre à la discussion à propos des patterns de sécurité des smart contract, rejoignez-nous sur Slack. Améliorons ensemble les standards de développement des smart contracts !

Pour suivre le travail d’OpenZeppelin sur la sécurité des smart contracts, suivez-les sur Medium et Twitter.

Publié avec l’accord préalable de Manuel Aráoz. Merci Romain Menetrier pour la relecture.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

%d blogueurs aiment cette page :

En continuant à utiliser le site, vous acceptez l’utilisation des cookies. Plus d’informations

Les paramètres des cookies sur ce site sont définis sur « accepter les cookies » pour vous offrir la meilleure expérience de navigation possible. Si vous continuez à utiliser ce site sans changer vos paramètres de cookies ou si vous cliquez sur "Accepter" ci-dessous, vous consentez à cela.

Fermer