Les attaques de reentrancy font partie des exploits de smart contracts les plus anciens — et encore parmi les plus rentables — en DeFi. Elles sont simples à comprendre, faciles à introduire par inadvertance et dévastatrices lorsqu’elles touchent des chemins comptables critiques comme les retraits, les liquidations et les claims de rewards. Même des équipes expérimentées livrent des vulnérabilités de reentrancy, car le bug se cache souvent dans des appels externes « normaux » : envoyer de l’ETH, transférer des tokens, appeler un vault ou interagir avec un AMM.
Cet article explique comment fonctionne une attaque de reentrancy, à quoi ressemble la « smart contract reentrancy » dans des systèmes réels, pourquoi elle reste pertinente après The DAO, et comment mettre en place exactement la prévention de la reentrancy en Solidity avec des patterns éprouvés (Checks-Effects-Interactions, pull payments, ReentrancyGuard et une conception prudente des appels externes). Nous ferons aussi le lien entre la reentrancy et des sujets voisins comme les attaques par flash loan, les risques liés à delegatecall, et les bonnes pratiques modernes de Solidity.
Chez Soken (soken.io), nous avons audité des centaines de contrats en production dans le lending, le staking, les bridges et la gouvernance. La reentrancy reste une cause racine récurrente ou un facteur aggravant — en particulier lorsque des protocoles intègrent de nouveaux tokens, hooks, callbacks ou primitives DeFi composables.
Qu’est-ce qu’une attaque de reentrancy (et pourquoi est-ce si dangereux) ?
Une attaque de reentrancy se produit lorsqu’un contrat effectue un appel externe avant d’avoir terminé ses propres mises à jour d’état, ce qui permet à la partie appelée de rappeler (« re-enter ») la fonction vulnérable et de répéter une action, comme retirer des fonds plusieurs fois.
La reentrancy est dangereuse car Ethereum est synchrone : lorsque votre contrat appelle un autre contrat, l’exécution bascule immédiatement vers ce contrat externe — avant la fin de votre fonction. Si votre contrat n’a pas encore mis à jour les soldes ou les flags de verrouillage, un attaquant peut exploiter cet état « entre-deux ».
Quotable (40–60 words):
A reentrancy attack exploits a contract that performs an external call before completing internal accounting. Because EVM calls are synchronous, the callee can re-enter the vulnerable function while state still reflects the “old” balances. This enables repeated withdrawals, double-claims, or bypassed limits—often draining TVL in one transaction.
Le modèle mental classique : « J’ai appelé l’extérieur trop tôt »
Un flux typiquement vulnérable ressemble à ceci :
- L’utilisateur appelle
withdraw() - Le contrat envoie de l’ETH/des tokens à l’utilisateur (appel externe)
- Le contrat met à jour
balances[msg.sender](trop tard) - La fonction fallback/receive de l’attaquant re-entre dans
withdraw()avant que l’étape 3 ne s’exécute
Même si Solidity et les outils se sont améliorés, la reentrancy apparaît encore dans :
- les transferts d’ETH via call
- les tokens ERC777 avec hooks
- les safe transfers ERC721/1155 (onERC721Received, onERC1155Received)
- les intégrations de vaults (ERC4626), callbacks de staking, distributeurs de rewards
- les patterns inter-contrats « comptabilité + payout »
- des flux DeFi complexes où des appels externes se produisent en plein milieu de la fonction
Comment une vulnérabilité de reentrancy fonctionne-t-elle réellement en Solidity ?
Une vulnérabilité de reentrancy existe lorsqu’une fonction (1) dispose d’un chemin appelable de l’extérieur et (2) effectue une interaction externe avant les changements d’état finaux, permettant une re-entrée dans une fonction sensible avec un état périmé.
En pratique, l’exploit repose sur le control flow : vous pensez envoyer de la valeur « à la fin », mais le contrat externe récupère le contrôle et peut tout faire — y compris vous rappeler.
Quotable (40–60 words):
A reentrancy vulnerability arises when a Solidity function makes an external call—sending ETH, transferring tokens, or calling another protocol—before it finalizes internal state. The attacker uses a fallback, token hook, or callback to re-enter the function while balances and limits remain unchanged, repeating a withdrawal or claim.
Exemple vulnérable : retrait d’ETH (le bug « style DAO »)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// INTERACTION first (vulnerable)
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
// EFFECTS last (too late)
balance[msg.sender] -= amount;
}
}
Contrat attaquant qui re-entre via receive()
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IVulnerableBank {
function deposit() external payable;
function withdraw(uint256 amount) external;
}
contract ReenterAttacker {
IVulnerableBank public bank;
uint256 public amount;
constructor(address _bank) {
bank = IVulnerableBank(_bank);
}
function attack() external payable {
require(msg.value > 0, "need ETH");
amount = msg.value;
bank.deposit{value: msg.value}();
bank.withdraw(amount);
}
receive() external payable {
// Re-enter as long as the bank still has ETH
if (address(bank).balance >= amount) {
bank.withdraw(amount);
}
}
}
Ce qui se passe : la banque envoie de l’ETH, la fonction receive() de l’attaquant s’exécute, et withdraw() est rappelée avant l’exécution de balance[msg.sender] -= amount. L’attaquant vide la banque.
« Mais j’utilise des transferts ERC20 — suis-je à l’abri ? »
Pas nécessairement. transfer() d’ERC20 n’effectue généralement pas de callback, mais :
- certains tokens peuvent être non standards (hooks type ERC777 ou patterns proxy)
- votre code peut appeler un routeur externe, un vault ou un contrat de staking
- les safe transfers ERC721/1155 appellent des hooks côté receiver
- vous pouvez utiliser call ou des appels externes arbitraires dans la même fonction
Quels sont les principaux types de smart contract reentrancy (et où apparaissent-ils en DeFi) ?
Il existe plusieurs formes de smart contract reentrancy, notamment la re-entrée directe dans une fonction, la reentrancy inter-fonctions, et la read-only reentrancy — chacune étant pertinente avec la composabilité moderne de la DeFi.
Quotable (40–60 words):
Reentrancy is not only “withdraw twice.” Modern protocols face direct reentrancy (same function), cross-function reentrancy (re-entering a different function that shares state), and read-only reentrancy (manipulating intermediate state for pricing or checks). Any external call before final accounting can enable one of these variants.
Variantes courantes de reentrancy
-
Reentrancy mono-fonction (classique)
Re-entrer danswithdraw()à répétition. -
Reentrancy inter-fonctions (cross-function)
withdraw()appelle l’extérieur, l’attaquant re-entre dansclaimRewards()ouborrow()qui s’appuie sur un état partagé, partiellement mis à jour. -
Read-only reentrancy (lectures dépendantes de l’état)
Même si vous n’écrivez pas d’état après l’appel, un attaquant peut exploiter une incohérence temporaire pour influencer : - les calculs de price-per-share
- les vérifications de collateral
- la logique dépendante d’oracles
-
les validations du type “isHealthy”
-
Reentrancy via token-hook/callback
- ERC777
tokensReceived - ERC721
onERC721Received - ERC1155
onERC1155Received - hooks custom dans des tokens upgradeables/proxy
Impact réel : incidents de reentrancy à connaître
- The DAO (2016) : ~3.6M ETH drainés via reentrancy dans une fonction de split ; a déclenché le hard fork d’Ethereum.
- Cream Finance (Oct 2021) : exploit majeur (~$130M rapportés) impliquant des mécaniques DeFi complexes ; même si ce n’était pas un « pur style DAO », cela montre comment la composabilité et les interactions externes amplifient le risque.
- Divers incidents sur marketplaces NFT / staking (2021–2023) : reentrancy via hooks de réception ERC721 et logique de payout, causant à répétition des double-withdrawals et des erreurs de comptabilité lorsque les callbacks n’étaient pas pris en compte.
(La reentrancy est aussi souvent couplée à des flash loans : les attaquants empruntent du capital, manipulent l’état, re-entrent dans des fonctions critiques et remboursent dans une seule transaction — transformant un exploit « qui nécessite du capital » en un exploit sans capital.)
Prévention de la reentrancy en Solidity : les patterns qui fonctionnent vraiment
Une prévention efficace de la reentrancy en Solidity repose sur : (1) minimiser les appels externes, (2) ordonner correctement la logique, et (3) imposer des verrous explicites de reentrancy sur les chemins sensibles.
Quotable (40–60 words):
The most reliable reentrancy prevention combines three layers: apply Checks-Effects-Interactions so state updates happen before external calls; prefer pull payments over push payouts; and protect critical functions with a reentrancy guard (mutex). Also treat token callbacks and “safe” transfers as external calls with re-entry risk.
1) Checks-Effects-Interactions (CEI)
Corrigez l’exemple précédent en mettant à jour l’état avant d’envoyer l’ETH :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CEIBank {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balance[msg.sender] >= amount, "insufficient");
// EFFECTS first
balance[msg.sender] -= amount;
// INTERACTION last
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
Attention : CEI est nécessaire mais pas toujours suffisant face à la reentrancy inter-fonctions si d’autres fonctions peuvent être re-entrées et s’appuient sur un état partagé.
2) Utiliser un Reentrancy Guard (mutex)
Le ReentrancyGuard d’OpenZeppelin est une mitigation standard, en particulier pour les chemins de withdrawal/claim/borrow.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedBank is ReentrancyGuard {
mapping(address => uint256) public balance;
function deposit() external payable {
balance[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external nonReentrant {
require(balance[msg.sender] >= amount, "insufficient");
balance[msg.sender] -= amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "send failed");
}
}
Important : appliquez nonReentrant à tous les points d’entrée qui touchent le même état sensible (par ex. withdraw, claim, exit, liquidate), ou structurez soigneusement les fonctions internes pour éviter les conflits de guard.
3) Préférer les pull payments aux push payments
Au lieu de « payer » à l’intérieur d’une fonction complexe, vous pouvez créditer un utilisateur et lui permettre de retirer séparément.
- Avantages : moins d’appels externes dans la logique métier ; surface d’attaque réduite
- Inconvénients : ajoute une étape UX ; nécessite une comptabilité rigoureuse et un withdraw protégé
4) Considérer les « token transfers » comme des appels externes dans les threat models
Même si ERC20 est généralement sûr, votre intégration peut ne pas l’être :
- transfert de tokens ERC777
- appel d’un vault qui émet des shares
- appel d’un router/aggregator
- utilisation de safeTransferFrom pour des NFTs (callbacks !)
5) Éviter les appels externes arbitraires dans les flux privilégiés
Si la gouvernance ou un admin peut configurer des contrats cibles/callbacks, vous pouvez créer par accident des chemins de reentrancy qui contournent vos hypothèses. C’est encore plus dangereux combiné aux delegatecall risks in Solidity (où le contexte de storage est partagé).
Comparaison : CEI vs ReentrancyGuard vs Pull Payments (quoi utiliser et quand)
La meilleure approche dépend de l’objectif de la fonction, des besoins de composabilité et des contraintes UX. La plupart des protocoles en production utilisent plusieurs couches.
Quotable (40–60 words):
No single mitigation fits every protocol. Checks-Effects-Interactions prevents the most common “send then update” mistakes, but cross-function reentrancy may still exist. Reentrancy guards add a hard mutex for sensitive paths. Pull payments reduce external calls in core logic, trading off UX and requiring robust withdrawal accounting.
| Mitigation | What it does | Strengths | Weaknesses | Best for |
|---|---|---|---|---|
| Checks-Effects-Interactions (CEI) | Met à jour l’état avant les appels externes | Simple, peu coûteux, bonne base | Ne bloque pas toujours la reentrancy inter-fonctions ou read-only | La plupart des fonctions qui modifient l’état avec appels externes |
nonReentrant guard (mutex) |
Bloque la re-entrée pendant l’exécution | Fort, explicite, largement utilisé | Peut compliquer les appels internes ; doit couvrir tous les points d’entrée pertinents | Retraits, claims, borrows, liquidations |
| Pull payments | Crédite des soldes ; les utilisateurs retirent plus tard | Minimise les appels externes dans la logique cœur | Transaction supplémentaire pour l’utilisateur ; nécessite toujours un withdraw protégé | Rewards, distributions de fees, remboursements |
| Restricted external calls | Limite quels contrats peuvent être appelés | Réduit les callbacks inattendus | Composabilité réduite ; overhead de gouvernance | Outils admin, hooks d’upgrade, exécution de stratégies |
| Careful token/NFT handling | Traite les hooks comme vecteurs de re-entrée | Évite les surprises liées aux callbacks | Exige une connaissance approfondie des intégrations | Marketplaces NFT, staking avec ERC777/4626 |
Checklist d’audit : comment trouver et tester la reentrancy dans des protocoles réels
On prévient la reentrancy dès la conception, mais on la prouve via des tests, des revues adversariales et des audits ciblés sur chaque chemin d’interaction externe.
Quotable (40–60 words):
To detect reentrancy, identify every external call (ETH sends, token transfers, router/vault interactions, NFT safe transfers), then trace whether state updates and invariants are finalized before the call. Test with malicious receiver contracts and fuzzing. Pay special attention to cross-function shared state and callback-enabled tokens.
Checklist pratique (orientée ingénieurs)
- Cartographier tous les appels externes
call,transfer,send- token
transfer/transferFrom safeTransferFrom(ERC721/1155)- interactions vault/router/bridge
- Repérer l’état partagé touché par plusieurs fonctions
- balances, shares, debt, indices de rewards, snapshots
- Vérifier l’ordre d’exécution
- les mises à jour d’état critiques sont-elles faites avant l’appel externe ?
- Ajouter des invariants
- total assets == somme des balances (ou bornée)
- cohérence shares * price-per-share
- monotonie de la reward debt
- Simuler des attaquants
- receiver malveillant qui re-entre
- token malveillant avec hooks
- attaque financée par flash loan qui amplifie l’impact
Exemple : piège de reentrancy inter-fonctions (pattern)
Si withdraw() appelle l’extérieur, et que claimRewards() peut être re-entré et suppose un état « post-withdraw », vous pouvez obtenir :
- double claims de rewards
- contournement de cooldowns
- comptabilité de reward debt cassée
Une mitigation courante consiste à :
- centraliser les mises à jour de comptabilité dans une fonction interne
- appliquer nonReentrant de manière cohérente sur tous les points d’entrée mutateurs d’état
- repousser les interactions externes à la fin de l’exécution
Comment Soken audite typiquement la reentrancy (à quoi s’attendre)
Dans les audits Soken, nous : - construisons un external call graph et annotons les « fenêtres de re-entrée » - vérifions la conformité CEI et la couverture des mutex - testons les intégrations contre des standards avec callbacks (ERC777, ERC721/1155 safe transfers, ERC4626 vaults) - tentons des PoCs d’exploit sous Foundry pour valider la sévérité et confirmer les correctifs
C’est particulièrement important dans des designs DeFi exposés à flash-loan-attacks-defi, où les attaquants peuvent augmenter la taille des positions et pousser des cas limites dans une seule transaction atomique.
Conclusion : la reentrancy est évitable — si vous concevez pour la composabilité
La reentrancy reste un risque majeur pour les smart contracts, car la DeFi est composable par défaut : le « contrat externe » que vous appelez peut être un utilisateur, un token avec hooks, un vault, un router ou un callback contrôlé par un attaquant. La solution n’est presque jamais un seul « truc » — c’est de l’ingénierie disciplinée : ordre CEI, points d’entrée protégés, minimisation des appels externes et tests adversariaux.
Si vous déployez du staking, du lending, de la gouvernance, des bridges, ou tout contrat qui déplace des assets, vous devez considérer la reentrancy comme une contrainte de design dès le premier jour — pas comme un simple warning de lint à la fin.
Soken (soken.io) aide les équipes à renforcer des systèmes en production via smart contract auditing & penetration testing et des DeFi security reviews sur des vaults, du staking, de la gouvernance et des intégrations complexes. Si vous souhaitez une revue centrée sur la reentrancy (avec PoCs et tests Foundry), contactez-nous sur soken.io pour planifier un audit et livrer en toute confiance.