# Attaque par Réentrance Expliquée — Comment Ça Fonctionne et Comment la Prévenir
La réentrance est l'une des vulnérabilités les plus dévastatrices en sécurité des smart contracts. Le célèbre hack de The DAO en 2016, qui a drainé plus de 60 millions de dollars d'Ether, a été causé par une attaque par réentrance. Malgré sa notoriété, des vulnérabilités de réentrance continuent d'apparaître dans les contrats modernes.
Dans cet article, nous allons explorer comment fonctionnent les attaques par réentrance, examiner différents types de réentrance, et apprendre des techniques de prévention éprouvées.
Qu'est-ce qu'une Attaque par Réentrance ?
Une attaque par réentrance se produit lorsqu'un appel de contrat externe est effectué avant que l'état soit mis à jour, permettant au contrat appelé de ré-entrer dans la fonction originale et d'exploiter un état incohérent.
Exemple Classique : Retrait Vulnérable
contract VulnerableBank {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// VULNÉRABLE : Appel externe avant mise à jour d'état
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "Insufficient balance");
// Appel externe AVANT mise à jour d'état
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// État mis à jour APRÈS l'appel externe
balances[msg.sender] -= amount;
}
}
Le Contrat Attaquant
contract Attacker {
VulnerableBank public bank;
uint256 constant WITHDRAW_AMOUNT = 1 ether;
constructor(address _bankAddress) {
bank = VulnerableBank(_bankAddress);
}
// 1. Déposer de l'ETH dans la banque
function attack() external payable {
require(msg.value >= WITHDRAW_AMOUNT);
bank.deposit{value: WITHDRAW_AMOUNT}();
bank.withdraw(WITHDRAW_AMOUNT);
}
// 2. Fallback reçoit l'ETH et ré-entre
receive() external payable {
if (address(bank).balance >= WITHDRAW_AMOUNT) {
bank.withdraw(WITHDRAW_AMOUNT);
}
}
}
Comment l'Attaque Fonctionne
withdraw(1 ether)VulnerableBank vérifie le solde (✓ 1 ETH)VulnerableBank envoie 1 ETH à l'attaquantreceive() de l'attaquant est déclenchéewithdraw(1 ether)VulnerableBank vérifie le solde (✓ toujours 1 ETH car pas encore mis à jour)VulnerableBank envoie 1 ETH supplémentaireTypes de Réentrance
1. Réentrance à Fonction Unique
Le type le plus simple — la même fonction est appelée de manière récursive.
function withdraw() external {
uint256 amount = balances[msg.sender];
// Appel externe avant mise à jour d'état
(bool success, ) = msg.sender.call{value: amount}("");
balances[msg.sender] = 0; // Trop tard !
}
2. Réentrance Cross-Fonction
Différentes fonctions partagent le même état, permettant la réentrance via une fonction alternative.
contract VulnerableCrossFunction {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
(bool success, ) = msg.sender.call{value: amount}("");
balances[msg.sender] = 0;
}
// Fonction différente utilisant le même état
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[to] += amount;
balances[msg.sender] -= amount;
}
}
Un attaquant peut appeler withdraw(), puis dans la callback receive(), appeler transfer() pour déplacer des fonds avant que le solde soit mis à zéro.
3. Réentrance Read-Only
Même les fonctions view peuvent être dangereuses si un contrat externe lit l'état pendant une mise à jour incohérente.
contract LendingPool {
mapping(address => uint256) public deposits;
IPriceOracle public oracle;
function withdraw(uint256 amount) external {
deposits[msg.sender] -= amount;
// Oracle lit l'état pendant la mise à jour
uint256 price = oracle.getPrice(); // Peut être manipulé !
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Si oracle.getPrice() effectue un appel externe qui déclenche une réentrance, il pourrait lire deposits[msg.sender] dans un état incohérent.
Techniques de Prévention
1. Pattern Checks-Effects-Interactions (CEI)
La méthode de défense la plus fondamentale : suivez toujours cet ordre :
require, assert)function withdraw(uint256 amount) external {
// 1. CHECKS
require(balances[msg.sender] >= amount, "Insufficient balance");
// 2. EFFECTS
balances[msg.sender] -= amount;
// 3. INTERACTIONS
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
Maintenant, si l'attaquant tente de ré-entrer, son solde est déjà à zéro.
2. Modificateur ReentrancyGuard
Le modificateur nonReentrant d'OpenZeppelin utilise un verrou pour bloquer les appels réentrants.
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] -= amount;
}
}
Comment ça fonctionne :
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
Le modificateur définit _status = _ENTERED avant l'exécution de la fonction. Toute tentative de réentrance échouera au require car _status est toujours _ENTERED.
3. Pattern Pull Over Push
Au lieu de pousser l'ETH vers les utilisateurs, laissez-les le retirer eux-mêmes.
contract SecureDistributor {
mapping(address => uint256) public pendingWithdrawals;
function setPayout(address user, uint256 amount) internal {
pendingWithdrawals[user] += amount;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0);
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
}
}
Cela isole le risque de réentrance à la fonction withdraw() elle-même, qui suit le pattern CEI.
4. Limites de Gas (Déprécié)
Historiquement, les développeurs utilisaient transfer() ou send() qui limitent le gas à 2300 (insuffisant pour un appel de fonction).
// DÉCONSEILLÉ — Ne vous fiez pas à ça
payable(msg.sender).transfer(amount);
Pourquoi c'est déprécié :
- EIP-1884 a augmenté le coût du gas pour
SLOAD, cassant certains contrats
- Limite la flexibilité (les wallets multisig ont besoin de plus de gas)
- Crée de fausses hypothèses de sécurité
Utilisez plutôt call avec CEI ou ReentrancyGuard.
Cas Réel : The DAO Hack (2016)
The DAO était un fonds d'investissement décentralisé qui a levé $150M. Un attaquant a exploité une vulnérabilité de réentrance dans la fonction splitDAO :
function splitDAO(...) {
// ...
// Transfert d'ETH AVANT mise à jour du solde
if (balances[msg.sender] > 0) {
if (!msg.sender.call.value(balances[msg.sender])()) {
throw;
}
}
// État mis à jour APRÈS le transfert
balances[msg.sender] = 0;
}
L'attaquant a drainé 3.6M ETH ($60M à l'époque) en appelant récursivement splitDAO. Cet événement a conduit à la controverse du hard fork Ethereum/Ethereum Classic.
Réentrance Cross-Contrat
La réentrance peut se produire entre contrats différents partageant l'état.
contract TokenVault {
mapping(address => uint256) public deposits;
IRewardPool public rewardPool;
function withdraw(uint256 amount) external {
deposits[msg.sender] -= amount;
// Interaction avec un autre contrat
rewardPool.updateRewards(msg.sender);
token.transfer(msg.sender, amount);
}
}
contract RewardPool {
function updateRewards(address user) external {
// Si cela déclenche une callback vers TokenVault, réentrance possible
uint256 userDeposits = vault.deposits(user);
// ...
}
}
Protection : Utilisez nonReentrant sur toutes les fonctions qui modifient l'état et interagissent avec des contrats externes.
Checklist de Prévention de la Réentrance
✅ Suivez toujours le pattern Checks-Effects-Interactions
✅ Utilisez le modificateur nonReentrant d'OpenZeppelin pour les fonctions critiques
✅ Mettez à jour l'état AVANT les appels externes
✅ Préférez le pattern pull-over-push pour les distributions
✅ Évitez transfer() et send() — utilisez call avec des gardes
✅ Auditez les interactions cross-contrat
✅ Testez avec des contrats malveillants simulés
✅ Utilisez des outils d'analyse statique (Slither, Mythril)
Outils pour Détecter la Réentrance
- Slither : Détecteur statique avec vérification de réentrance
- Mythril : Analyse symbolique pour les vulnérabilités
- Foundry Invariant Testing : Teste les propriétés qui doivent toujours être vraies
- Trail of Bits Echidna : Fuzzer pour smart contracts
# Installer Slither
pip3 install slither-analyzer
# Analyser un contrat
slither contracts/MyContract.sol --detect reentrancy-eth
Conclusion
La réentrance reste l'une des vulnérabilités les plus critiques en développement Solidity. En suivant le pattern CEI, en utilisant ReentrancyGuard, et en appliquant le pattern pull-over-push, vous pouvez éliminer ce vecteur d'attaque de vos contrats.
Points clés à retenir :
- La réentrance se produit lorsque l'état est mis à jour APRÈS un appel externe
- Suivez TOUJOURS Checks-Effects-Interactions
- Utilisez le modificateur
nonReentrantd'OpenZeppelin
- Testez avec des contrats d'attaque simulés
- Les audits automatisés aident mais ne remplacent pas la revue manuelle
Avec Solingo, pratiquez la détection et la prévention de la réentrance à travers des exercices interactifs qui simulent des scénarios d'attaque réels.