The Attack That Proves Flash Loan Defenses Are Still Broken
On January 20, 2026, an attacker drained approximately $4.2 million (1,299 ETH) from Makina Finance's DUSD/USDC Curve stableswap pool using a flash loan oracle manipulation that took seconds to execute and months to recover from.
The attack wasn't novel. The technique—borrow massive capital via flash loan, manipulate pricing inputs, extract value, repay loan—has been the number one DeFi exploit pattern since 2020. What makes the Makina case instructive isn't the attack itself, but why the defenses failed and what battle-tested patterns could have prevented it.
As of March 2026, flash loan attacks have drained over $15 million from DeFi protocols in Q1 alone (Makina's $4.2M, sDOLA's $239K, BUBU2's $19.7K, Curve DUSD/USDC MEV frontrun, and several smaller incidents). The pattern isn't going away. The question is whether your protocol is defended against it.
Anatomy of the Makina Exploit
Understanding the defense patterns requires understanding the attack. Here's exactly what happened:
Step 1: Flash Loan Acquisition
The attacker borrowed ~$280 million in USDC from Morpho and Aave V2. No collateral required—flash loans must be repaid within the same transaction or the entire transaction reverts.
Step 2: Multi-Pool Liquidity Injection
The borrowed USDC was injected into multiple Curve Finance pools simultaneously:
- Makina's DUSD/USDC pool
- DAI/USDC/USDT (3pool)
- MIM-related pools
This wasn't random—each pool was chosen because Makina's oracle system referenced them for pricing.
Step 3: Oracle Manipulation via calc_withdraw_one_coin()
Here's the critical vulnerability. Makina's MachineShareOracle relied on Curve's calc_withdraw_one_coin() function to determine pool asset values:
// Simplified version of what Makina's oracle did
function getSharePrice() external view returns (uint256) {
// This reads CURRENT pool state — manipulable within a single tx
uint256 aum = caliber.calculateAUM();
uint256 totalShares = totalSupply();
return aum * 1e18 / totalShares;
}
// Inside Caliber's AUM calculation
function calculateAUM() public view returns (uint256) {
uint256 total = 0;
for (uint i = 0; i < positions.length; i++) {
// calc_withdraw_one_coin reads current pool balances
// NOT time-weighted averages
uint256 value = curvePool.calc_withdraw_one_coin(
positions[i].lpBalance,
usdcIndex
);
total += value;
}
return total;
}
By injecting $280M into the referenced pools, the attacker inflated calc_withdraw_one_coin() results, which inflated calculateAUM(), which inflated sharePrice from ~1.01 to ~1.33.
Step 4: Value Extraction
With the artificially inflated share price, the attacker swapped a small amount of USDC for a disproportionately large amount of DUSD, then arbitraged the difference. Repeat until the pool was drained.
Step 5: Flash Loan Repayment
All borrowed funds were repaid within the same transaction. An MEV bot front-ran part of the exploit, capturing ~276 ETH. The attacker consolidated ~1,023 ETH in external addresses.
Total time: one block. Total cost to attacker: gas fees (~$50). Total damage: $4.2 million.
Defense Pattern 1: Time-Weighted Oracle Architecture
The root cause of most flash loan oracle attacks is reliance on spot prices. Spot prices can be manipulated within a single transaction. Time-weighted prices cannot (or at least, the cost of manipulation scales with the time window).
The Wrong Way
// ❌ VULNERABLE — reads current pool state
function getPrice(address pool) public view returns (uint256) {
return ICurvePool(pool).get_virtual_price();
// Or: calc_withdraw_one_coin, balances(), etc.
}
The Right Way: Multi-Block TWAP
// ✅ SECURE — time-weighted average over multiple blocks
contract TWAPOracle {
struct Observation {
uint256 timestamp;
uint256 cumulativePrice;
}
Observation[] public observations;
uint256 public constant TWAP_WINDOW = 30 minutes;
uint256 public constant MIN_OBSERVATIONS = 10;
function recordObservation(uint256 currentPrice) external {
observations.push(Observation({
timestamp: block.timestamp,
cumulativePrice: observations.length > 0
? observations[observations.length - 1].cumulativePrice +
currentPrice * (block.timestamp - observations[observations.length - 1].timestamp)
: currentPrice
}));
}
function getTWAP() public view returns (uint256) {
require(observations.length >= MIN_OBSERVATIONS, "insufficient data");
uint256 targetTimestamp = block.timestamp - TWAP_WINDOW;
// Find observation closest to target timestamp
Observation memory oldest = _findObservation(targetTimestamp);
Observation memory newest = observations[observations.length - 1];
uint256 timeElapsed = newest.timestamp - oldest.timestamp;
require(timeElapsed >= TWAP_WINDOW / 2, "TWAP window too short");
return (newest.cumulativePrice - oldest.cumulativePrice) / timeElapsed;
}
}
Why it works: A flash loan exists for exactly one block (~12 seconds on Ethereum). A 30-minute TWAP window means the attacker would need to maintain their manipulated position for 30 minutes of real time, across ~150 blocks. The capital cost of maintaining $280M in borrowed funds for 30 minutes (interest + opportunity cost) makes the attack economically unviable.
Capital Cost Analysis
Flash loan cost (1 block): ~$50 in gas
Flash loan cost (30 min): Impossible — flash loans must repay same block
Regular loan (30 min): ~$280M × 5% APR × (30/525600) = ~$26,600 interest
+ Price risk exposure: $280M at risk for 30 minutes
The attack becomes unprofitable long before the TWAP window expires.
Defense Pattern 2: Multi-Source Price Validation
Don't rely on a single price source. Cross-reference multiple independent oracles and reject transactions when they diverge.
contract MultiSourceOracle {
IChainlinkOracle public chainlinkFeed;
IUniswapV3Pool public uniswapPool;
ICurvePool public curvePool;
uint256 public constant MAX_DEVIATION = 200; // 2% in basis points
function getValidatedPrice(address token) public view returns (uint256) {
uint256 chainlinkPrice = _getChainlinkPrice(token);
uint256 uniswapTWAP = _getUniswapTWAP(token);
uint256 curvePrice = _getCurveVirtualPrice(token);
// Require at least 2 of 3 sources agree within tolerance
uint256 agreements = 0;
uint256 consensusPrice;
if (_withinDeviation(chainlinkPrice, uniswapTWAP)) {
agreements++;
consensusPrice = (chainlinkPrice + uniswapTWAP) / 2;
}
if (_withinDeviation(chainlinkPrice, curvePrice)) {
agreements++;
if (consensusPrice == 0) {
consensusPrice = (chainlinkPrice + curvePrice) / 2;
}
}
if (_withinDeviation(uniswapTWAP, curvePrice)) {
agreements++;
if (consensusPrice == 0) {
consensusPrice = (uniswapTWAP + curvePrice) / 2;
}
}
require(agreements >= 1, "Oracle price divergence detected");
return consensusPrice;
}
function _withinDeviation(
uint256 a,
uint256 b
) internal pure returns (bool) {
uint256 diff = a > b ? a - b : b - a;
uint256 avg = (a + b) / 2;
return (diff * 10000 / avg) <= MAX_DEVIATION;
}
}
In the Makina case: The attacker manipulated Curve pool balances, but Chainlink's price feed (updated by off-chain oracles with multi-minute lag) wouldn't have reflected the manipulation. A multi-source oracle would have detected the divergence and paused the operation.
Defense Pattern 3: Flash Loan Detection and Circuit Breakers
You can detect flash loan attacks in real-time by monitoring for abnormal activity within a single block.
Same-Block Detection
contract FlashLoanGuard {
mapping(address => uint256) private _lastInteractionBlock;
mapping(address => uint256) private _blockInteractionCount;
uint256 public constant MAX_BLOCK_INTERACTIONS = 3;
modifier flashLoanProtected() {
if (_lastInteractionBlock[msg.sender] == block.number) {
_blockInteractionCount[msg.sender]++;
require(
_blockInteractionCount[msg.sender] <= MAX_BLOCK_INTERACTIONS,
"Suspicious activity: too many same-block interactions"
);
} else {
_lastInteractionBlock[msg.sender] = block.number;
_blockInteractionCount[msg.sender] = 1;
}
_;
}
function swap(
uint256 amountIn,
uint256 amountOutMin
) external flashLoanProtected {
// ... swap logic
}
}
Share Price Change Circuit Breaker
contract SharePriceGuard {
uint256 public lastKnownSharePrice;
uint256 public lastSharePriceUpdate;
uint256 public constant MAX_SHARE_PRICE_CHANGE = 500; // 5% in basis points
uint256 public constant PRICE_COOLDOWN = 1 hours;
modifier sharePriceStable() {
uint256 currentPrice = _calculateSharePrice();
if (lastKnownSharePrice > 0) {
uint256 change = currentPrice > lastKnownSharePrice
? currentPrice - lastKnownSharePrice
: lastKnownSharePrice - currentPrice;
uint256 changeBps = change * 10000 / lastKnownSharePrice;
require(
changeBps <= MAX_SHARE_PRICE_CHANGE,
"Share price moved too fast — possible manipulation"
);
}
_;
// Update after successful execution
lastKnownSharePrice = _calculateSharePrice();
lastSharePriceUpdate = block.timestamp;
}
}
In the Makina case: The share price jumped from 1.01 to 1.33 in a single transaction—a 31% change. A 5% circuit breaker would have blocked the exploit at the extraction phase.
Defense Pattern 4: Withdrawal Delay Mechanisms
For vault-style protocols (which Makina's DUSD/USDC pool effectively was), delayed withdrawals eliminate flash loan attacks entirely.
contract DelayedWithdrawVault {
struct WithdrawalRequest {
uint256 shares;
uint256 requestTime;
uint256 snapshotSharePrice; // Price at request time
bool executed;
}
mapping(address => WithdrawalRequest[]) public withdrawalRequests;
uint256 public constant WITHDRAWAL_DELAY = 1 hours;
uint256 public constant MAX_SHARE_PRICE_GROWTH = 100; // 1% per hour
function requestWithdrawal(uint256 shares) external {
require(balanceOf(msg.sender) >= shares, "insufficient shares");
// Lock shares immediately
_transfer(msg.sender, address(this), shares);
withdrawalRequests[msg.sender].push(WithdrawalRequest({
shares: shares,
requestTime: block.timestamp,
snapshotSharePrice: getSharePrice(),
executed: false
}));
}
function executeWithdrawal(uint256 requestIndex) external {
WithdrawalRequest storage req = withdrawalRequests[msg.sender][requestIndex];
require(!req.executed, "already executed");
require(
block.timestamp >= req.requestTime + WITHDRAWAL_DELAY,
"withdrawal delay not met"
);
// Use the LOWER of snapshot price and current price
// This prevents "withdraw at manipulated price" attacks
uint256 currentPrice = getSharePrice();
uint256 effectivePrice = currentPrice < req.snapshotSharePrice
? currentPrice
: req.snapshotSharePrice;
uint256 maxAllowedPrice = req.snapshotSharePrice *
(10000 + MAX_SHARE_PRICE_GROWTH) / 10000;
if (currentPrice > maxAllowedPrice) {
effectivePrice = maxAllowedPrice;
}
req.executed = true;
uint256 amountOut = req.shares * effectivePrice / 1e18;
_burn(address(this), req.shares);
IERC20(underlyingAsset).transfer(msg.sender, amountOut);
}
}
Why it works: Flash loans must be repaid within the same block. A 1-hour withdrawal delay means the attacker cannot extract funds within the flash loan's lifecycle. The min-price mechanism also prevents manipulation at the request time from being exploited.
Defense Pattern 5: ERC-4626 totalAssets() Hardening
Many of these attacks target ERC-4626 vaults where totalAssets() reads manipulable external state. If your vault wraps Curve LP positions, AMM pools, or lending markets, the totalAssets() function is your primary attack surface.
contract HardenedVault is ERC4626 {
uint256 private _cachedTotalAssets;
uint256 private _lastCacheUpdate;
uint256 public constant CACHE_DURATION = 15 minutes;
uint256 public constant MAX_ASSETS_CHANGE_PER_PERIOD = 300; // 3%
function totalAssets() public view override returns (uint256) {
uint256 liveAssets = _calculateLiveAssets();
if (_lastCacheUpdate == 0) return liveAssets;
// If cache is fresh, use bounded live assets
uint256 maxAllowed = _cachedTotalAssets * (10000 + MAX_ASSETS_CHANGE_PER_PERIOD) / 10000;
uint256 minAllowed = _cachedTotalAssets * (10000 - MAX_ASSETS_CHANGE_PER_PERIOD) / 10000;
if (liveAssets > maxAllowed) return maxAllowed;
if (liveAssets < minAllowed) return minAllowed;
return liveAssets;
}
function updateAssetsCache() external {
require(
block.timestamp >= _lastCacheUpdate + CACHE_DURATION,
"Cache still fresh"
);
uint256 liveAssets = _calculateLiveAssets();
// Gradual update — can't jump more than MAX_ASSETS_CHANGE_PER_PERIOD
if (_cachedTotalAssets > 0) {
uint256 maxAllowed = _cachedTotalAssets * (10000 + MAX_ASSETS_CHANGE_PER_PERIOD) / 10000;
uint256 minAllowed = _cachedTotalAssets * (10000 - MAX_ASSETS_CHANGE_PER_PERIOD) / 10000;
if (liveAssets > maxAllowed) liveAssets = maxAllowed;
if (liveAssets < minAllowed) liveAssets = minAllowed;
}
_cachedTotalAssets = liveAssets;
_lastCacheUpdate = block.timestamp;
}
}
The key insight: totalAssets() should never reflect instantaneous external state changes. Capping the rate of change ensures that even if external prices are manipulated, the vault's internal accounting moves slowly enough to prevent profitable extraction.
The Defense Hierarchy
Not all defenses are equal. Here's the priority order:
| Priority | Defense | Flash Loan Attack Cost | Implementation Complexity |
|---|---|---|---|
| 1 | Multi-block TWAP oracle | Makes attack impossible | Medium |
| 2 | Multi-source price validation | Requires manipulating multiple venues | Medium |
| 3 | Share price circuit breakers | Caps damage per transaction | Low |
| 4 | Withdrawal delays | Makes attack structurally impossible | Low |
| 5 | Same-block interaction limits | Catches naive attacks | Low |
| 6 |
totalAssets() rate limiting |
Bounds maximum extraction | Medium |
Minimum viable defense: implement patterns 1 + 3. If you're reading pool balances or calling calc_withdraw_one_coin() in any pricing path, you are vulnerable today.
Testing Your Defenses: A Practical Checklist
Before deploying any DeFi protocol that holds user funds:
- [ ] Can any pricing function be called with manipulated pool state? Trace every
viewfunction that influences token pricing back to its data sources. If any source reads current pool balances, you're vulnerable. - [ ] What happens if
totalAssets()doubles in one block? Simulate it. If the protocol allows profitable extraction, add rate limiting. - [ ] Does your oracle survive a $500M flash loan? Write a Foundry fork test that flash-borrows from Aave/Morpho and tries to extract value.
- [ ] Is there a withdrawal delay? If not, why not? For vaults and lending pools, delayed withdrawals are the simplest defense.
- [ ] Do you have circuit breakers? Share price changes > 5% per block should pause the protocol, not execute trades.
Foundry Fork Test Template
function testFlashLoanAttack() public {
// Fork mainnet
vm.createSelectFork("mainnet");
// Record pre-attack state
uint256 preAttackSharePrice = vault.getSharePrice();
uint256 preAttackTVL = vault.totalAssets();
// Simulate flash loan
deal(address(USDC), address(this), 280_000_000e6);
// Inject into pools
USDC.approve(address(curvePool), type(uint256).max);
curvePool.add_liquidity([280_000_000e6, 0], 0);
// Check if share price moved
uint256 postManipSharePrice = vault.getSharePrice();
uint256 priceChange = postManipSharePrice > preAttackSharePrice
? postManipSharePrice - preAttackSharePrice
: preAttackSharePrice - postManipSharePrice;
// Share price should NOT move significantly from pool manipulation
assertLt(
priceChange * 10000 / preAttackSharePrice,
100, // Less than 1% change
"CRITICAL: Share price manipulable via flash loan"
);
}
The Broader Pattern
Every major flash loan exploit in 2026 follows the same three-stage structure:
- Borrow — acquire temporary capital (flash loan, no cost)
- Distort — manipulate a pricing input the protocol trusts
- Extract — use the distorted price to withdraw more than deposited
Breaking any one stage prevents the attack:
- Break borrowing: Not possible — flash loans are a protocol primitive
- Break distortion: Use TWAP oracles, multi-source validation, rate limiting
- Break extraction: Use withdrawal delays, circuit breakers, per-block limits
Makina Finance's post-incident response was solid — they quickly contained the damage, provided LP exit paths, and identified on-chain clues about the attacker. But containment is cleanup, not prevention. The next protocol doesn't need to learn this lesson the hard way.
The $280 million flash loan cost the attacker $50 in gas. The $4.2 million it extracted cost Makina's LPs everything they had in that pool. The defense patterns in this article cost a few hundred lines of Solidity. The math is straightforward.
References
- Makina Finance Incident Post-Mortem (January 2026)
- BlockSec Weekly Web3 Security Roundup, Mar 2–8, 2026
- Curve Finance:
calc_withdraw_one_coin()Documentation - OWASP Smart Contract Top 10 2026: SC04 — Flash Loan Facilitated Attacks
- EIP-4626: Tokenized Vaults
This article is part of the DeFi Security Research series. Follow @ohmygod for weekly deep-dives into smart contract vulnerabilities, audit techniques, and security tooling.
Top comments (0)