Sécurité·8 min de lecture·Par Solingo

Attaque par Réentrance Expliquée — Comment Ça Fonctionne et Comment la Prévenir

Comprendre la vulnérabilité de réentrance qui a mené au hack de The DAO et apprendre des techniques éprouvées pour protéger vos smart contracts de ce vecteur d'attaque critique.

# 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

  • L'attaquant dépose 1 ETH
  • L'attaquant appelle withdraw(1 ether)
  • VulnerableBank vérifie le solde (✓ 1 ETH)
  • VulnerableBank envoie 1 ETH à l'attaquant
  • La fonction receive() de l'attaquant est déclenchée
  • L'attaquant appelle à nouveau withdraw(1 ether)
  • VulnerableBank vérifie le solde (✓ toujours 1 ETH car pas encore mis à jour)
  • VulnerableBank envoie 1 ETH supplémentaire
  • Répétition jusqu'à ce que le contrat soit vidé
  • Finalement, l'état est mis à jour pour toutes les executions imbriquées
  • Types 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 :

  • Checks : Valider les conditions (require, assert)
  • Effects : Mettre à jour l'état
  • Interactions : Appeler des contrats externes
  • 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 nonReentrant d'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.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement