Dans ce troisième article de la série Soliditips, nous étudierons la faille de ré-entrée. Cette mauvaise pratique de développement d’un smart contract a déjà causé quelques millions de dollars de pertes et la fracture de la communauté Ethereum (17 juin 2017, The DAO, 50 M$). C’est une bonne raison pour se pencher sur le sujet …

Considérons le contrat Dropbox suivant. Il permet à chacun de venir déposer des Ethers et de les retirer par la suite.

pragma solidity ^0.7.5;

contract DropBox{
    mapping (address => uint) public accounts;

    constructor() payable {
    }

    function drop() public payable {
        accounts[msg.sender] = msg.value;
    }

    function withdraw() public {
        (bool success,) = msg.sender.call{value: accounts[msg.sender]}("");
        if (! success ) {
            revert();
        }
        accounts[msg.sender] = 0;
    }

    fallback() external payable {
        revert();
    }
}

Il possède un attribut accounts de type mapping qui va associer les balances de compte à chaque adresse utilisée pour déposer des Ethers.

Une fonction drop va être appelée dans une transaction qui lui envoie des Ethers. Elle va associer la somme reçue à l’adresse qui l’envoie. Le contrat devient donc propriétaire des Ethers reçus et enregistre combien il a reçu de chaque adresse. (Pour simplifier l’exemple, ici on va gérer uniquement un dépôt unique et non la possibilité de cumuler plusieurs dépôts avec la même adresse)

Une fonction withdraw permet de récupérer ses Ethers. En l’appelant avec l’adresse ayant servit au dépôt, le smart contract va nous envoyer la somme initialement déposée et remettre notre balance à zéro.

Voyons maintenant un autre contrat, DropboxThief, qui va chercher à dérober l’intégralité des sommes déposées dans Dropbox :

pragma solidity ^0.7.5;

import "./Dropbox.sol";

contract DropboxThief {
    DropBox public dropbox;
    
    constructor (address payable _dropbox) payable {
        dropbox = DropBox(_dropbox);
        dropbox.drop{value: msg.value}();
        dropbox.withdraw();
    }
     
    fallback() external payable {
        dropbox.withdraw();
    } 
}

Il va tout d’abord déposer une somme en appelant drop puis la retirer immédiatement avec withdraw.

Pourtant, de la façon dont il agit, et à cause d’une erreur dans Dropbox, il va également pouvoir récupérer les Ethers déposés par d’autres personnes.

La faille de ré-entrée

Le concept au cœur de la faille de ré-entrée est la fonction de fallback, judicieusement appelée … fallback.

Cette fonction ne prend aucun paramètre et doit être payable et external. Elle est appelée par défaut lors de l’activation d’un contrat, quand aucune des autres fonctions n’est explicitement appelée.

Elle sera donc appelée lorsque Dropbox va envoyer les Ethers à DropboxThief lors de l’appel à withdraw.

Jusque là, nous sommes dans le fonctionnement normal des smart contracts.

Le problème vient du fait que dans la fonction withdraw, la balance est remise à zéro après l’envoi des Ethers.

La fonction de fallback est appelée immédiatement lors de l’activation du smart contract DropboxThief. Lors de son exécution, le contrat n’a pas encore remis à zéro la balance de l’appelant. Si la fonction de fallback effectue un nouvel appel à withdraw, Dropbox lui renverra à nouveau la somme qu’il lui a déjà envoyée juste avant. Et ainsi de suite, par appels successifs, jusqu’à ce que l’envoi de nouveaux Ethers ne puisse plus se faire, soit parce que Dropbox n’en possède plus aucun, soit parce que tout le gaz de la transaction withdraw initiale a été consommé.

A noter également, l’envoi des Ethers utilise la fonction call, qui consomme autant de gaz que nécessaire et que fourni par l’appelant.

Comment corriger ça ?

Pour corriger cette faille, il suffit simplement de mettre les balances à zéro avant d’envoyer les Ethers et d’utiliser la fonction transfer pour l’envoi, qui limite la consommation de gaz. La fonction withdraw se trouvera alors définie de cette façon :

    function withdraw() public {
        uint amount = accounts[msg.sender];
        if(amount <= 0) {
            revert();
        }
        accounts[msg.sender] = 0;
        msg.sender.transfer(amount);
    }

On appelle cette façon de faire : Checks-Effects-Interactions pattern. Elle consiste à effectuer les opérations dans un ordre précis, pour n’importe quelle fonction de smart contract.

  • Check : d’abord, effectuer toutes les vérifications.
  • Effects : ensuite, appliquer les modifications aux variables.
  • Interactions : et seulement à la fin, réaliser les appels aux autres smart contracts ou les transferts d’Ethers.

Fallback et receive

Avant Solidity 0.6.0, la fonction de fallback était une fonction anonyme.
Depuis Solidity 0.6.0, elle a été séparée en 2 fonctions, nommées receive, appelée lors d’une activation du contrat sans appel de méthode mais avec envoi d’Ether ; et fallback, appelée soit lors d’un appel de contrat sans appel de méthode ni envoi d’Ether, soit si la fonction receive n’est pas définie. Notre exemple illustre ce dernier cas.

Et maintenant …

Nous avons démontré ici la faille de ré-entrée avec un vol d’Ethers. Mais elle s’applique bien entendu à tous types d’interactions avec des smart contracts.

Alors si vous ne voulez pas vous faire cambrioler, fermez bien les portes !

Liens