Qu’est-ce qu’une attaque de réentrée et comment fonctionne-t-elle ?
Voici comment se déroule l’un des hacks de contrats intelligents les plus courants qui coûte des millions aux entreprises du Web 3…
Certains des plus gros piratages de l’industrie de la blockchain, au cours desquels des millions de dollars de jetons de crypto-monnaie ont été volés, ont résulté d’attaques de réentrée. Bien que ces piratages soient devenus moins courants ces dernières années, ils constituent toujours une menace importante pour les applications et les utilisateurs de la blockchain.
Alors, que sont précisément les attaques de réentrée ? Comment sont-ils déployés ? Et existe-t-il des mesures que les développeurs peuvent prendre pour éviter que cela ne se produise ?
Qu’est-ce qu’une attaque de réentrée ?
Une attaque de réentrance se produit lorsqu'une fonction de contrat intelligent vulnérable effectue un appel externe à un contrat malveillant, abandonnant temporairement le contrôle du flux de transaction. Le contrat malveillant appelle ensuite à plusieurs reprises la fonction initiale du contrat intelligent avant de terminer son exécution, tout en drainant ses fonds.
Essentiellement, une transaction de retrait sur la blockchain Ethereum suit un cycle en trois étapes : confirmation du solde, versement et mise à jour du solde. Si un cybercriminel parvient à détourner le cycle avant la mise à jour du solde, il peut retirer des fonds à plusieurs reprises jusqu'à ce qu'un portefeuille soit vidé.
L'un des hacks de blockchain les plus célèbres, le hack Ethereum DAO, couvert par Coindesk, était une attaque de réentrée qui a entraîné une perte de plus de 60 millions de dollars d'Eth et a fondamentalement changé le cours de la deuxième plus grande crypto-monnaie.
Comment fonctionne une attaque de réentrée ?
Imaginez une banque dans votre ville natale où des habitants vertueux gardent leur argent ; sa liquidité totale est de 1 million de dollars. Cependant, le système comptable de la banque est défectueux : le personnel attend jusqu'au soir pour mettre à jour les soldes bancaires.
Votre ami investisseur visite la ville et découvre la faille comptable. Il crée un compte et dépose 100 000 $. Un jour plus tard, il retire 100 000 $. Au bout d'une heure, il tente à nouveau de retirer 100 000 $. Comme la banque n'a pas mis à jour son solde, celui-ci indique toujours 100 000 $. Alors il reçoit l'argent. Il fait cela à plusieurs reprises jusqu'à ce qu'il ne reste plus d'argent. Les employés ne se rendent compte qu'il n'y a pas d'argent que lorsqu'ils équilibrent les comptes le soir.
Dans le cadre d’un smart contract, le processus se déroule comme suit :
- Un cybercriminel identifie un contrat intelligent « X » présentant une vulnérabilité.
- L'attaquant initie une transaction légitime sur le contrat cible, X, pour envoyer des fonds vers un contrat malveillant, « Y ». Lors de l'exécution, Y appelle la fonction vulnérable dans X.
- L'exécution du contrat de X est suspendue ou retardée pendant que le contrat attend une interaction avec l'événement externe
- Pendant que l'exécution est suspendue, l'attaquant appelle à plusieurs reprises la même fonction vulnérable dans X, déclenchant à nouveau son exécution autant de fois que possible.
- À chaque rentrée, l'état du contrat est manipulé, permettant à l'attaquant de drainer des fonds de X vers Y.
- Une fois les fonds épuisés, la rentrée s'arrête, l'exécution retardée de X se termine enfin et l'état du contrat est mis à jour en fonction de la dernière rentrée.
Généralement, l’attaquant réussit à exploiter la vulnérabilité de réentrée à son avantage, en volant les fonds du contrat.
Un exemple d'attaque de réentrée
Alors, comment techniquement une attaque de réentrée pourrait-elle se produire exactement une fois déployée ? Voici un hypothétique contrat intelligent avec une passerelle de réentrée. Nous utiliserons une dénomination axiomatique pour faciliter le suivi.
// Vulnerable contract with a reentrancy vulnerability
pragma solidity ^0.8.0;
contract VulnerableContract { mapping(address => uint256) private balances;
function deposit() public payable { balances[msg.sender] += msg.value; }
function withdraw(uint256 amount) public { require(amount <= balances[msg.sender], "Insufficient balance"); (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed"); balances[msg.sender] -= amount; }} Le VulnerableContract permet aux utilisateurs de déposer des eth dans le contrat à l'aide de la fonction de dépôt. Les utilisateurs peuvent ensuite retirer leur eth déposé en utilisant la fonction retirer. Cependant, il existe une vulnérabilité de réentrance dans la fonction retrait. Lorsqu'un utilisateur se retire, le contrat transfère le montant demandé à l'adresse de l'utilisateur avant de mettre à jour le solde, créant ainsi une opportunité à exploiter pour un attaquant.
Voici maintenant à quoi ressemblerait le contrat intelligent d’un attaquant.
// Attacker's contract to exploit the reentrancy vulnerability
pragma solidity ^0.8.0;
interface VulnerableContractInterface { function withdraw(uint256 amount) external;}
contract AttackerContract { VulnerableContractInterface private vulnerableContract; address private targetAddress;
constructor(address _vulnerableContractAddress) { vulnerableContract = VulnerableContractInterface(_vulnerableContractAddress); targetAddress = msg.sender; }
// Function to trigger the attack function attack() public payable { // Deposit some ether to the vulnerable contract vulnerableContract.deposit{value: msg.value}();
// Call the vulnerable contract's withdraw function vulnerableContract.withdraw(msg.value); }
// Receive function to receive funds from the vulnerable contract receive() external payable { if (address(vulnerableContract).balance >= 1 ether) { // Reenter the vulnerable contract's withdraw function vulnerableContract.withdraw(1 ether); } }
// Function to steal the funds from the vulnerable contract function withdrawStolenFunds() public { require(msg.sender == targetAddress, "Unauthorized"); (bool success, ) = targetAddress.call{value: address(this).balance}(""); require(success, "Transfer failed"); }} Lorsque l'attaque est lancée :
- L'AttackerContract prend l'adresse du VulnerableContract dans son constructeur et la stocke dans la variable vulnerableContract.
- La fonction d'attaque est appelée par l'attaquant, déposant de l'éth dans le VulnerableContract à l'aide de la fonction deposit, puis appelant immédiatement la fonction retirer<. fonction du VulnerableContract.
- La fonction retirer du VulnerableContract transfère le montant demandé d'eth vers le AttackerContract de l'attaquant avant de mettre à jour le solde, mais comme le contrat de l'attaquant est suspendu pendant l'appel externe, la fonction n'est pas encore terminée.
- La fonction receive dans AttackerContract est déclenchée car le VulnerableContract a envoyé eth à ce contrat lors de l'appel externe.
- La fonction de réception vérifie si le solde du AttackerContract est d'au moins 1 éther (le montant à retirer), puis elle réintègre le VulnerableContract en appelant son retrait. fonctionner à nouveau.
- Les étapes trois à cinq se répètent jusqu'à ce que le VulnerableContract soit à court de fonds et que le contrat de l'attaquant accumule une quantité substantielle d'eth.
- Enfin, l'attaquant peut appeler la fonction withdrawStolenFunds dans l'AttackerContract pour voler tous les fonds accumulés dans son contrat.
L'attaque peut se produire très rapidement, en fonction des performances du réseau. Lorsqu’il s’agit de contrats intelligents complexes tels que le DAO Hack, qui a conduit au hard fork d’Ethereum vers Ethereum et Ethereum Classic, l’attaque se déroule sur plusieurs heures.
Comment prévenir une attaque de réentrée
Pour éviter une attaque de réentrance, nous devons modifier le contrat intelligent vulnérable afin de suivre les meilleures pratiques pour le développement sécurisé de contrats intelligents. Dans ce cas, nous devrions implémenter le modèle "checks-effects-interactions" comme dans le code ci-dessous.
// Secure contract with the "checks-effects-interactions" pattern
pragma solidity ^0.8.0;
contract SecureContract { mapping(address => uint256) private balances; mapping(address => bool) private isLocked;
function deposit() public payable { balances[msg.sender] += msg.value; }
function withdraw(uint256 amount) public { require(amount <= balances[msg.sender], "Insufficient balance"); require(!isLocked[msg.sender], "Withdrawal in progress"); // Lock the sender's account to prevent reentrancy isLocked[msg.sender] = true;
// Perform the state change balances[msg.sender] -= amount;
// Interact with the external contract after the state change (bool success, ) = msg.sender.call{value: amount}(""); require(success, "Transfer failed");
// Unlock the sender's account isLocked[msg.sender] = false; }} Dans cette version corrigée, nous avons introduit un mappage isLocked pour savoir si un compte particulier est en cours de retrait. Lorsqu'un utilisateur initie un retrait, le contrat vérifie si son compte est verrouillé (!isLocked[msg.sender]), indiquant qu'aucun autre retrait depuis le même compte n'est actuellement en cours.
Si le compte n'est pas verrouillé, le contrat se poursuit avec le changement d'état et l'interaction externe. Après le changement d'état et l'interaction externe, le compte est à nouveau déverrouillé, permettant ainsi de futurs retraits.
Types d'attaques de réentrée
Généralement, il existe trois principaux types d’attaques de réentrée en fonction de leur nature d’exploitation.
- Attaque de réentrée unique : dans ce cas, la fonction vulnérable que l'attaquant appelle à plusieurs reprises est la même que celle qui est sensible à la passerelle de réentrée. L'attaque ci-dessus est un exemple d'attaque par réentrée unique, qui peut être facilement évitée en mettant en œuvre des contrôles et des verrous appropriés dans le code.
- Attaque interfonctionnelle : dans ce scénario, un attaquant exploite une fonction vulnérable pour appeler une fonction différente au sein du même contrat qui partage un état avec la fonction vulnérable. La deuxième fonction, appelée par l'attaquant, a un effet souhaitable, la rendant plus attractive pour son exploitation. Cette attaque est plus complexe et plus difficile à détecter. Des contrôles et des verrouillages stricts sur les fonctions interconnectées sont donc nécessaires pour l'atténuer.
- Attaque croisée : cette attaque se produit lorsqu'un contrat externe interagit avec un contrat vulnérable. Lors de cette interaction, l'état du contrat vulnérable est appelé dans le contrat externe avant qu'il ne soit entièrement mis à jour. Cela se produit généralement lorsque plusieurs contrats partagent la même variable et que certains mettent à jour la variable partagée de manière non sécurisée. Des protocoles de communication sécurisés entre les contrats et des audits périodiques des contrats intelligents doivent être mis en œuvre pour atténuer cette attaque.
Les attaques de réentrée peuvent se manifester sous différentes formes et nécessitent donc des mesures spécifiques pour les empêcher.
Se protéger des attaques de réentrée
Les attaques par réentrée ont entraîné des pertes financières substantielles et miné la confiance dans les applications blockchain. Pour protéger les contrats, les développeurs doivent adopter les meilleures pratiques avec diligence pour éviter les vulnérabilités de réentrance.
Ils devraient également mettre en œuvre des modèles de retrait sécurisés, utiliser des bibliothèques fiables et mener des audits approfondis pour renforcer davantage la défense du contrat intelligent. Bien entendu, rester informé des menaces émergentes et être proactif dans les efforts de sécurité peut également garantir le respect de l'intégrité des écosystèmes blockchain.


