Sécurité et wallets · 6 min · démo interactive

L'attaque par réentrance rejouée en direct

Deux lignes dans le mauvais ordre ont coûté environ 3,6 millions d'ETH et scindé Ethereum en deux. Rejoue l'attaque, puis inverse ces lignes.

L’idée en une phrase

Une réentrance se produit quand un contrat envoie de l'argent avant de mettre à jour ses comptes, ce qui permet au destinataire de le rappeler pour se faire payer une seconde fois.

Imagine un guichetier qui te remet tes billets, puis se tourne pour noter le retrait. Tu demandes un nouveau retrait avant qu'il n'écrive. Ton solde affiche toujours le même montant, donc il paie encore. Sur Ethereum, ce tour de main est possible parce qu'un envoi d'ETH à un contrat exécute le code de ce contrat.

La démo

Le résultat de l'attaque s'affiche ici.
20 ETHsolde réel de la banque
0 ETHsolde du contrat attaquant
0 ETHsolde de l'attaquant dans le registre
–profondeur d'appel maximale
–gas de l'attaque, indicatif

Étapes de l'exécution

  1. Les étapes apparaissent ici.

Sans JavaScript, voici le déroulé chiffré. La banque détient 20 ETH de ses clients. L'attaquant dépose 2 ETH et retire : la banque lui envoie 2 ETH sans avoir encore mis son solde à zéro. Il rappelle withdraw() pendant la réception et reçoit 2 ETH de plus, et ainsi de suite. Après 11 retraits imbriqués, la banque est vide : l'attaquant repart avec 22 ETH pour 2 ETH de mise.

Ce n'est pas une animation écrite d'avance. Les trois contrats sont du vrai bytecode, exécuté avec de vrais appels imbriqués dans notre mini-EVM pédagogique, un sous-ensemble limité d'Ethereum, et non un nœud complet. La banque est un cas d'école : elle reproduit le mécanisme de The DAO, pas son code ni ses montants. Le gas reste indicatif.

Intégrer cette démo

Copie ce code sur ton site, ton cours ou ton blog. La démo reste gratuite ; garde simplement le lien vers protocole.org.

Pourquoi ça compte

En juin 2016, The DAO, un fonds géré par contrat qui avait levé des millions d'ETH auprès du public, se vide en direct. Un attaquant retire ses parts en boucle grâce à ce schéma et détourne environ 3,6 millions d'ETH vers un contrat fils, où les règles du projet bloquent les fonds pendant plusieurs semaines.

Ce délai ouvre un débat sans précédent. Le code avait-il raison, parce que « le code fait loi » ? La communauté choisit majoritairement une modification exceptionnelle du protocole : un hard fork, activé en juillet 2016, qui transfère les fonds vers un contrat de remboursement. Une minorité refuse et continue la chaîne d'origine, qui devient Ethereum Classic. Une erreur d'ordre dans quelques lignes a ainsi produit deux blockchains.

Le code

Voici la même attaque en code exécutable. Passe CORRIGE à true : l'ordre des deux opérations s'inverse et l'attaque échoue. Modifie aussi le montant de la mise.

Clique sur Lancer pour exécuter ce code dans ton navigateur.

Ce qui se passe vraiment

Voici l'équivalent simplifié en Solidity de la version vulnérable. Le bytecode de la démo a été assemblé à la main. Compilée avec solc et rejouée dans une EVM de référence, cette source donne les mêmes soldes finaux que notre version.

function withdraw() external {
    uint256 b = balanceOf[msg.sender];
    require(b > 0);
    (bool ok, ) = msg.sender.call{value: b}("");   // 1. envoie
    require(ok);
    balanceOf[msg.sender] = 0;                     // 2. met à jour, trop tard
}

L'attaquant est un contrat dont la fonction de réception rappelle withdraw(), tant que la banque peut payer :

receive() external payable {
    if (address(banque).balance >= mise) banque.withdraw();
}

Regarde les étapes de la version vulnérable. Après chaque ligne « La banque envoie », l'attaquant rappelle aussitôt, et la profondeur d'appel grimpe de deux crans à chaque aller-retour. Le compteur « solde dans le registre » ne bouge pas : il reste à la mise de départ pendant que le solde réel fond. C'est l'instant précis de la faille : pendant l'appel externe, l'état du contrat est incohérent et un tiers a la main.

Le graphique montre le solde réel chuter jusqu'à zéro, puis un plateau. Ce plateau, c'est le bas de la liste : quand la banque est vide, les appels se dénouent en cascade et chacun écrit enfin zéro dans le registre. Aucun REVERT, aucune erreur : l'EVM a exécuté exactement le programme qu'on lui a donné.

Choisis maintenant « Corrigé ». Le solde interne passe à zéro avant l'envoi. L'attaquant rappelle toujours withdraw(), mais require(b > 0) échoue et cet appel seul est annulé. Il récupère sa mise, rien de plus. Cette règle porte un nom, checks-effects-interactions :

  • Checks : vérifier les conditions.
  • Effects : écrire les changements d'état.
  • Interactions : appeler l'extérieur en dernier.

Le correctif tient en un déplacement de ligne, sans ajout de vérification.

Dans la vraie vie

The DAO n'avait pas exactement cette banque. Sa fonction de sortie transférait les fonds avant de remettre à zéro le solde du participant, et la récursion passait par ce chemin : le schéma reste le même. Le hard fork a été activé au bloc 1 920 000, et Ethereum Classic poursuit depuis la chaîne non modifiée. Ce précédent reste discuté, car il montre qu'une communauté peut réécrire un résultat quand l'enjeu est assez grand.

Les protections courantes se complètent. Le schéma checks-effects-interactions reste la première défense. Un verrou de réentrance, comme le ReentrancyGuard d'OpenZeppelin, bloque tout rappel pendant l'exécution. Le modèle de retrait différé, ou pull payment, sépare l'enregistrement de la créance et son paiement. Un audit, enfin, cherche ce schéma de façon systématique.

Le risque n'a pas disparu avec Solidity 0.8. Les vérifications automatiques de débordement peuvent bloquer certaines variantes : si le code fait balanceOf[msg.sender] -= b après l'envoi, le dénouement échoue et annule tout. Ce n'est qu'un effet de bord utile, notre banque le montre : avec une simple mise à zéro, l'attaque passe. En juillet 2023, un défaut du compilateur Vyper a cassé des verrous de réentrance dans des pools Curve, ce qui a permis des vols. Le schéma resurgit aussi dans les AMM et chez les oracles de prix qui lisent leur état.

Ce qui peut mal tourner

  • Réentrance sur une seule fonction. Le cas de la démo : un appel externe avant la mise à jour de l'état.
  • Réentrance entre fonctions. withdraw est protégée, mais pendant l'envoi, l'attaquant appelle transfer, qui lit le même solde périmé. Il faut raisonner sur tout le contrat.
  • Réentrance en lecture seule. Un autre protocole lit un prix ou un solde au milieu d'une opération incomplète. Le contrat lui-même ne perd rien, c'est son voisin qui est trompé.
  • Rappels cachés. Certaines normes appellent le destinataire, comme safeTransferFrom d'ERC-721 ou les crochets de tokens ERC-777. Tout appel externe est une interaction, même s'il ne ressemble pas à un paiement.
  • Se fier à la limite de 2 300 gas. transfer et send la fixent, mais le coût des instructions a déjà changé par le passé. Ce n'est pas une garantie durable.
  • Déposer dans un contrat non audité. Pour un utilisateur, aucune action ne corrige ce défaut : un contrat vulnérable peut se vider sans que tu signes quoi que ce soit.

Teste-toi

1. Quelle est la cause d'une réentrance dans cette démo ?
2. Que dit le schéma checks-effects-interactions ?
3. Quelle a été la conséquence de l'attaque de The DAO en 2016 ?