ITSkillsCenter
Développement Web

Sécuriser un smart contract Solidity : reentrancy, tests et bonnes pratiques

13 دقائق للقراءة
Guide principal : Développer des smart contracts sur Ethereum : Solidity, outils et web3.

Un smart contract est public et immuable : tout le monde peut lire son code, et une fois déployé, on ne peut plus le corriger. Cette combinaison rend la sécurité non négociable. Une faille n’est pas un bug qu’on patche le lendemain — c’est une porte ouverte que des acteurs malveillants exploitent en quelques secondes. La vulnérabilité la plus emblématique s’appelle la reentrancy (réentrance). Nous allons la reproduire concrètement, comprendre pourquoi elle fonctionne, puis la corriger de deux manières.

Pour rester dans le fil de cette série, nous travaillons sur un coffre : un contrat où des utilisateurs déposent et retirent des fonds. C’est le terrain de jeu classique de la réentrance, et le prétexte parfait pour installer de vrais réflexes de sécurité.

🎯 Ce que vous allez apprendre

  • Comprendre le mécanisme d’une attaque par réentrance.
  • Reproduire l’attaque dans un test Foundry, preuve à l’appui.
  • Corriger par le motif Checks-Effects-Interactions.
  • Renforcer avec le garde ReentrancyGuard d’OpenZeppelin.
  • Adopter les bonnes pratiques transversales et les tests par fuzzing.

🛠️ Ce que vous allez construire

Un contrat coffre volontairement vulnérable, un contrat attaquant qui le vide, un test qui démontre le vol, puis deux versions corrigées dont vous prouverez par un test qu’elles résistent. Vous repartirez avec un schéma mental clair de la faille et de sa parade.

Prérequis

  • Un projet Foundry fonctionnel avec OpenZeppelin installé.
  • L’aisance acquise dans les tutoriels précédents (écriture, test, événements).
  • ⏱️ Temps estimé : 60 à 80 minutes.

Étape 1 — Écrire un coffre vulnérable

On commence par la version fautive, pour voir le problème en face. Le coffre laisse déposer des fonds et les retirer. L’erreur, subtile mais classique, est l’ordre des opérations dans le retrait : le contrat envoie les fonds avant de remettre le solde à zéro.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

contract CoffreVulnerable {
    mapping(address => uint256) public soldes;

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

    function retirer() external {
        uint256 montant = soldes[msg.sender];
        require(montant > 0, "rien a retirer");
        (bool ok, ) = msg.sender.call{value: montant}("");
        require(ok, "echec envoi");
        soldes[msg.sender] = 0; // BUG : mise a jour APRES l'envoi
    }
}

Le piège est la ligne d’envoi. Quand retirer appelle msg.sender.call, si msg.sender est un contrat, ce contrat reçoit la main et peut rappeler retirer immédiatement — avant que soldes[msg.sender] ait été remis à zéro. Le solde est donc toujours considéré comme intact, et le retrait peut se rejouer en boucle. C’est ça, la réentrance : ré-entrer dans une fonction avant qu’elle ait fini de mettre à jour son état.

Étape 2 — Écrire le contrat attaquant

Pour démontrer l’exploitation, on écrit un contrat qui dépose un peu, déclenche un retrait, et profite de la fonction receive — appelée automatiquement à la réception de fonds — pour rappeler retirer tant que le coffre n’est pas vide.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {CoffreVulnerable} from "./CoffreVulnerable.sol";

contract Attaquant {
    CoffreVulnerable public coffre;

    constructor(address _coffre) {
        coffre = CoffreVulnerable(_coffre);
    }

    function attaquer() external {
        coffre.deposer{value: 1 ether}();
        coffre.retirer();
    }

    receive() external payable {
        if (address(coffre).balance >= 1 ether) {
            coffre.retirer();
        }
    }
}

Le scénario est limpide. L’attaquant dépose 1 ether, puis appelle retirer. Le coffre lui renvoie son ether, ce qui déclenche receive ; tant que le coffre contient encore au moins 1 ether (les dépôts d’autres utilisateurs), receive rappelle retirer, qui renvoie encore — et ainsi de suite jusqu’à épuisement. Le solde de l’attaquant n’ayant jamais été remis à zéro entre deux appels, chaque retrait lui paraît légitime.

Étape 3 — Prouver l’attaque par un test

Un test vaut mieux qu’un long discours. On approvisionne le coffre avec le dépôt d’un utilisateur honnête, on lance l’attaque, et on constate que le coffre est vidé bien au-delà de la mise de l’attaquant. Créez test/Reentrancy.t.sol :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {CoffreVulnerable} from "../src/CoffreVulnerable.sol";
import {Attaquant} from "../src/Attaquant.sol";

contract ReentrancyTest is Test {
    CoffreVulnerable private coffre;

    function setUp() public {
        coffre = new CoffreVulnerable();
        address alice = address(0xA11CE);
        vm.deal(alice, 5 ether);
        vm.prank(alice);
        coffre.deposer{value: 5 ether}();
    }

    function test_LAttaqueVideLeCoffre() public {
        Attaquant attaquant = new Attaquant(address(coffre));
        vm.deal(address(attaquant), 1 ether);

        attaquant.attaquer();

        assertEq(address(coffre).balance, 0);
        assertGt(address(attaquant).balance, 1 ether);
    }
}

La fonction setUp simule un utilisateur honnête, Alice, qui dépose 5 ether (vm.deal crédite une adresse en ether de test). Le coffre contient donc 6 ether au total après le dépôt de l’attaquant. Lancez le test :

forge test --match-test test_LAttaqueVideLeCoffre -vvv

Le test passe — ce qui, ici, est une mauvaise nouvelle : il prouve que le coffre a bien été vidé et que l’attaquant repart avec plus que sa mise. La trace -vvv montre les appels imbriqués à retirer, rendant la boucle de réentrance visible à l’œil nu.

Point d’étape — Vous avez reproduit une attaque réelle. Le coffre vulnérable est à zéro, l’attaquant enrichi des dépôts d’autrui. Vous tenez la preuve que l’ordre des opérations est un sujet de sécurité, pas de style.

Étape 4 — Corriger par Checks-Effects-Interactions

La parade la plus fondamentale ne coûte rien : c’est un ordre. Le motif Checks-Effects-Interactions impose de toujours, dans une fonction, (1) vérifier les conditions, (2) modifier l’état, puis (3) seulement à la fin interagir avec l’extérieur. Appliqué au retrait, il suffit de remettre le solde à zéro avant l’envoi :

function retirer() external {
    uint256 montant = soldes[msg.sender];
    require(montant > 0, "rien a retirer");
    soldes[msg.sender] = 0;                 // EFFET d'abord
    (bool ok, ) = msg.sender.call{value: montant}(""); // INTERACTION ensuite
    require(ok, "echec envoi");
}

Avec cet ordre, quand l’attaquant ré-entre dans retirer, son solde vaut déjà zéro : le require(montant > 0) échoue et la boucle s’arrête net. La faille disparaît sans aucune bibliothèque externe, par la seule discipline d’écriture. Refaites tourner le test d’attaque contre cette version : il échoue désormais à vider le coffre, exactement ce qu’on veut.

Étape 5 — Renforcer avec ReentrancyGuard

Le bon ordre suffit, mais sur des fonctions complexes il est facile de se tromper. OpenZeppelin fournit une seconde ligne de défense : ReentrancyGuard, dont le modificateur nonReentrant empêche toute ré-entrée dans une fonction protégée, quelle que soit la logique interne.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract CoffreSecurise is ReentrancyGuard {
    mapping(address => uint256) public soldes;

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

    function retirer() external nonReentrant {
        uint256 montant = soldes[msg.sender];
        require(montant > 0, "rien a retirer");
        soldes[msg.sender] = 0;
        (bool ok, ) = msg.sender.call{value: montant}("");
        require(ok, "echec envoi");
    }
}

Le modificateur nonReentrant pose un verrou en entrée de fonction et le lève en sortie ; toute tentative de rappeler la fonction pendant son exécution est rejetée. On combine ici les deux protections : le bon ordre et le garde. C’est la défense en profondeur — si une protection est contournée par une erreur future, l’autre tient encore. Écrivez un test qui lance l’attaquant contre CoffreSecurise et vérifiez, avec vm.expectRevert, que l’attaque échoue.

Étape 6 — Tester par fuzzing

Au-delà des cas que vous imaginez, Foundry sait générer des entrées aléatoires pour éprouver une propriété : c’est le fuzzing. Une fonction de test dont un paramètre n’est pas fixé est automatiquement fuzzée. Vérifions une propriété qui doit toujours tenir : après un dépôt suivi d’un retrait, l’utilisateur récupère exactement sa mise et son solde retombe à zéro.

function testFuzz_DepotPuisRetrait(uint96 montant) public {
    vm.assume(montant > 0);
    address utilisateur = address(0xD00D);
    vm.deal(utilisateur, montant);

    vm.startPrank(utilisateur);
    coffre.deposer{value: montant}();
    uint256 avant = utilisateur.balance;
    coffre.retirer();
    vm.stopPrank();

    assertEq(utilisateur.balance, avant + montant);
    assertEq(coffre.soldes(utilisateur), 0);
}

Foundry exécute ce test des centaines de fois avec des valeurs de montant variées ; vm.assume écarte les cas hors périmètre (ici, un montant nul). Le type uint96 borne les valeurs à des ordres de grandeur réalistes. Si une seule entrée casse une assertion, Foundry l’affiche et la « réduit » à un contre-exemple minimal — un atout considérable pour débusquer les cas limites que des tests fixes manqueraient.

Bonnes pratiques transversales

La réentrance n’est qu’une faille parmi d’autres. Quelques principes réduisent fortement la surface d’attaque. Respectez systématiquement Checks-Effects-Interactions, même quand vous croyez ne pas en avoir besoin. Préférez call pour envoyer de l’ether plutôt que les anciennes méthodes transfer et send, dont le plafond de gaz fixe casse certains contrats destinataires. Adoptez le modèle « pull plutôt que push » : laissez les utilisateurs retirer eux-mêmes leurs fonds au lieu de les leur envoyer en masse. Verrouillez chaque fonction sensible par un contrôle d’accès explicite. Vérifiez toujours la valeur de retour des appels externes. Et appuyez-vous sur des bibliothèques auditées comme OpenZeppelin plutôt que de réinventer des primitives délicates.

Enfin, aucune relecture ne remplace les tests : couvrez les cas nominaux, les cas d’échec attendus, et complétez par du fuzzing sur vos invariants. Pour un contrat amené à gérer une valeur réelle, un audit indépendant avant déploiement reste la norme du métier. La sécurité n’est pas une case à cocher en fin de projet, c’est une manière d’écrire à chaque ligne.

Pourquoi la réentrance est possible

La faille n’est pas un défaut de Solidity, mais une conséquence du modèle d’exécution. Quand un contrat envoie de l’ether à une adresse, si cette adresse est un contrat, du code s’y exécute dans la même transaction, avant que l’appel d’origine ne se poursuive. Le contrôle passe donc temporairement à un tiers, qui peut faire ce qu’il veut — y compris rappeler la fonction qui vient de lui donner la main. Tant que cette fonction n’a pas encore mis à jour son état, elle présente une photographie périmée de la réalité, et c’est cette fenêtre que l’attaquant exploite.

Comprendre cela généralise la leçon : tout appel externe est un point de rupture. Dès qu’une fonction sort de son propre périmètre pour parler à un autre contrat, elle doit considérer que n’importe quoi peut se produire entre-temps. C’est pourquoi le réflexe « état d’abord, interaction ensuite » n’est pas une recette ponctuelle contre la réentrance, mais une règle générale de prudence qui protège contre toute une famille de problèmes liés aux appels externes.

Au-delà de la réentrance

D’autres failles méritent votre vigilance. Les dépassements arithmétiques (un solde qui « tourne » au-delà de sa capacité) étaient une plaie historique ; depuis Solidity 0.8, ils provoquent une annulation automatique, mais il faut rester attentif dès qu’on utilise un bloc unchecked pour optimiser. Le contrôle d’accès mal posé reste la cause numéro un d’incidents : une fonction sensible sans onlyOwner, ou une vérification basée sur tx.origin au lieu de msg.sender, ouvre grand la porte. Les appels externes dont on ignore la valeur de retour masquent des échecs silencieux. Enfin, certaines logiques peuvent être bloquées par un destinataire malveillant qui refuse de recevoir des fonds, d’où l’intérêt du modèle « pull plutôt que push ».

Aucune liste n’est exhaustive, et c’est précisément le point : la sécurité d’un contrat ne se résume pas à éviter une faille connue, mais à raisonner sur ce qu’un adversaire motivé pourrait tenter à chaque appel. Reproduire une attaque, comme nous venons de le faire, est le meilleur entraînement à ce raisonnement — on apprend à défendre en ayant pensé comme l’attaquant.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
Fonds vidés malgré un solde correct Envoi avant mise à jour de l’état Appliquer Checks-Effects-Interactions
Retrait qui échoue chez certains destinataires Usage de transfer et son plafond de gaz Utiliser call et vérifier la valeur de retour
ReentrancyGuardReentrantCall Le garde a bloqué une ré-entrée (comportement voulu) C’est le signe que la protection fonctionne
Test de fuzzing qui échoue aléatoirement Une assertion ne tient pas pour toutes les entrées Lire le contre-exemple réduit fourni par Foundry
Valeur de retour d’un call ignorée Échec d’envoi non détecté Toujours vérifier require(ok, ...)

✅ Récapitulatif

Vous avez reproduit une attaque par réentrance, prouvé le vol par un test, puis fermé la faille de deux façons complémentaires : le motif Checks-Effects-Interactions et le garde ReentrancyGuard. Vous avez ajouté un test de fuzzing pour éprouver un invariant, et vous disposez d’une liste de bonnes pratiques applicables à tous vos contrats. Surtout, vous avez intégré l’idée centrale : sur une chaîne où le code est public et figé, la sécurité se conçoit dès la première ligne.

🧾 Aide-mémoire

Élément Rôle
Checks-Effects-Interactions Vérifier, modifier l’état, puis interagir
ReentrancyGuard / nonReentrant Verrou contre la ré-entrée
call{value: x}("") Envoi d’ether recommandé (vérifier le retour)
vm.deal(addr, x) Créditer une adresse en ether de test
vm.expectRevert(...) Exiger l’échec d’un appel
testFuzz_... Test à entrées aléatoires
vm.assume(cond) Filtrer les entrées de fuzzing

💪 À vous de jouer

Écrivez le test qui prouve que CoffreSecurise résiste : lancez le même attaquant contre la version protégée et vérifiez que l’attaque ne vide pas le coffre. Indice : l’appel imbriqué étant bloqué, l’attaque entière sera annulée.

Voir une piste
function test_CoffreSecuriseResiste() public {
    CoffreSecurise sur = new CoffreSecurise();
    address alice = address(0xA11CE);
    vm.deal(alice, 5 ether);
    vm.prank(alice);
    sur.deposer{value: 5 ether}();

    AttaquantSecurise att = new AttaquantSecurise(address(sur));
    vm.deal(address(att), 1 ether);

    vm.expectRevert();
    att.attaquer();

    assertEq(address(sur).balance, 5 ether);
}

Adaptez le contrat attaquant pour qu’il cible CoffreSecurise ; le verrou nonReentrant fera échouer l’appel imbriqué, et donc toute la transaction.

Tutoriels frères

Pour aller plus loin

FAQ

Le motif Checks-Effects-Interactions suffit-il sans ReentrancyGuard ?
Souvent oui, et c’est la protection la plus fondamentale. Mais sur des fonctions complexes ou qui appellent plusieurs contrats, ajouter nonReentrant apporte une seconde barrière à coût négligeable. La défense en profondeur est rarement de trop.

Pourquoi préférer call à transfer ?
Parce que transfer impose un plafond de gaz fixe qui peut faire échouer des destinataires légitimes (portefeuilles intelligents, contrats). call n’a pas ce plafond, à condition de toujours vérifier sa valeur de retour et de respecter Checks-Effects-Interactions.

Des tests verts garantissent-ils la sécurité ?
Non. Ils prouvent que les comportements testés sont corrects. La sécurité combine tests, fuzzing, revues, bibliothèques éprouvées et, pour les contrats à enjeu réel, un audit indépendant.

مشاركة
Service ITSkillsCenter

Application mobile Android et iOS

Création d'application mobile Android et iOS. À partir de 350 000 FCFA.

Démarrer mon projet
Publicité