Trouver le bug de réentrance : checks-effects-interactions en Solidity
Le défi
Ce coffre Solidity envoie de l'ether à l'appelant avant de mettre à jour le solde de l'appelant. Un contrat malveillant peut rappeler pendant cet intervalle et vider le coffre. Lisez le contrat et tapez le nom de la fonction qui contient ce défaut.
Ce que tu vas apprendre
- Reconnaître un appel externe placé avant une mise à jour d'état comme un point d'entrée pour la réentrance
- Appliquer le modèle checks-effects-interactions pour auditer une fonction
- Expliquer comment un fallback malveillant réentre dans un contrat pour en vider les fonds
- Distinguer une fonction sûre qui ne fait que modifier l'état d'une fonction qui effectue un appel externe
- Nommer les correctifs appropriés : réordonner les effets avant les interactions ou utiliser un garde
Compétences testées
Prérequis
- Syntaxe Solidity de base (mappings, msg.sender, call)
- Comment les transferts d'ether peuvent invoquer une fonction fallback
- Ce qu'est l'état d'un contrat (balances)
Comment ça marche
La réentrance est la vulnérabilité classique des smart contracts à l'origine de certaines des pertes DeFi les plus importantes. Elle survient lorsqu'un contrat effectue un appel externe avant d'avoir terminé la mise à jour de son propre état. L'appel externe peut céder le contrôle à un contrat attaquant, qui rappelle la fonction d'origine avant que l'état ne soit stabilisé, si bien que le contrat agit sur des données obsolètes.
Dans Vault.sol, withdraw() lit bal = balances[msg.sender], puis transfère l'ether avec msg.sender.call{value: bal}(""), et ne met balances[msg.sender] = 0 qu'ensuite. L'appel bas niveau call déclenche la fonction fallback du destinataire. Le fallback d'un contrat malveillant se contente de rappeler withdraw(), et comme son solde n'a pas encore été remis à zéro, le test require(bal > 0) passe encore et il est payé une nouvelle fois. En répétant cette boucle, il vide entièrement le coffre.
Le principe enfreint est checks-effects-interactions : effectuer les vérifications (require), puis appliquer les effets (mettre à jour les soldes), et seulement ensuite réaliser les interactions (appels externes). deposit() respecte trivialement ce principe : elle met à jour l'état sans effectuer d'appel externe, elle est donc sûre et sert de leurre. La fonction vulnérable est withdraw, et la compétence d'audit consiste à repérer que la mise à jour de l'état intervient après l'appel.
Erreurs fréquentes
- Nommer deposit(). Elle ne fait que mettre à jour l'état et n'effectue aucun appel externe, elle ne peut donc pas être réentrée.
- Incriminer msg.sender.call de façon générique. L'appel en lui-même n'est pas le problème ; le bug vient de sa position avant la mise à jour du solde. Nommez la fonction, pas seulement l'appel.
- Supposer que les vérifications de dépassement de Solidity 0.8 aident. Les maths sécurisées n'empêchent pas la réentrance, c'est l'ordre des opérations qui compte.
- Penser que require(bal > 0) est le garde-fou. Ce test repasse à chaque réentrée car le solde est encore non nul pendant l'appel.
Comment s'en protéger
Terminez toujours la mise à jour de l'état avant d'effectuer un appel externe. Réordonnez withdraw() pour que le solde soit remis à zéro en premier, et ajoutez un garde pour une défense en profondeur.
- Respectez checks-effects-interactions : définissez
balances[msg.sender] = 0avant lecall. - Utilisez un garde de réentrance (par exemple le modificateur
nonReentrantd'OpenZeppelin) sur les fonctions qui déplacent des fonds. - Préférez les modèles de paiement en pull plutôt que de pousser de l'ether au sein d'une logique complexe.
- Auditez chaque
call/transferexterne à la recherche d'un état encore modifiable au moment où le contrôle quitte le contrat.