TL;DR
On January 20, 2026, Makina Finance lost ~$4M when an attacker borrowed $280M via flash loans, manipulated external Curve pool balances to inflate Makina's internal AUM calculations, and drained the DUSD/USDC pool—all in a single transaction. The root cause: trusting external pool state as a price oracle without flash-loan resistance.
The Attack Vector: External Pool State as an Implicit Oracle
Most developers know not to use slot0() for price feeds. Fewer realize that calling calc_withdraw_one_coin() on an external Curve pool is functionally the same thing—a spot-price oracle masquerading as a utility function.
Makina's Caliber contract computed its Assets Under Management (AUM) by querying external Curve pools:
// Simplified vulnerable pattern
function computeAUM() internal view returns (uint256) {
// Reading external pool balances as "truth"
uint256 curveBalance = ICurvePool(EXTERNAL_POOL).calc_withdraw_one_coin(
lpTokenBalance,
USDC_INDEX
);
return curveBalance + internalReserves;
}
This pattern treats the current state of an external liquidity pool as ground truth. In normal conditions, it works. Under flash loan manipulation, it's a loaded gun.
Step-by-Step Reconstruction
Phase 1: Capital Assembly
The attacker borrowed ~$280M USDC across two flash loan sources:
| Source | Amount |
|---|---|
| Morpho | ~160.59M USDC |
| Aave V2 | ~119.41M USDC |
Spreading across multiple sources reduced the chance of hitting individual pool limits.
Phase 2: Position Setup
- Added 100M USDC to Makina's DUSD/USDC pool → received LP tokens
- Swapped 10M USDC → ~9.215M DUSD
This established the attacker's position inside Makina's ecosystem before the manipulation began.
Phase 3: External Pool Manipulation (The Kill Shot)
This is where it gets interesting. The attacker didn't manipulate Makina's pools directly for the price distortion—they manipulated the external Curve pools that Makina's accounting read from:
- Added 170M USDC to Curve's DAI/USDC/USDT (3pool)
- Added 30M liquidity to Curve MIM-3LP3CRV-f pool
- Partially withdrew from MIM pool to receive MIM tokens
These operations temporarily bloated the external pool balances. When Makina's accountForPosition() function called calc_withdraw_one_coin() on these pools, it returned inflated values because the pool now had massively skewed reserves.
Phase 4: AUM Inflation → SharePrice Manipulation
With external pool balances artificially inflated:
sharePrice = totalAUM / totalShares
Before manipulation: sharePrice ≈ 1.01
After manipulation: sharePrice ≈ 1.33 (↑ 31.7%)
A 31.7% increase in share price within a single transaction. No legitimate market movement could produce this.
Phase 5: Profit Extraction
The inflated sharePrice let the attacker withdraw more USDC per LP token than they deposited. The arbitrage loop drained the majority of USDC reserves from the DUSD/USDC pool.
Phase 6: Cleanup
- Repaid both flash loans (~$280M)
- Converted profits to ~1,299 ETH (~$4M)
- Transferred to external addresses
Plot twist: An MEV bot front-ran part of the attack, capturing ~276 ETH. Even exploiters get exploited.
The Root Cause: Trust Boundaries
The vulnerability wasn't a bug in the traditional sense—no integer overflow, no reentrancy. It was an architectural trust assumption: treating the current state of external pools as reliable input for accounting.
┌─────────────────────────────────────────────┐
│ TRUST BOUNDARY │
│ │
│ Makina's Internal State ← TRUSTED │
│ ↕ │
│ External Curve Pool State ← UNTRUSTED │
│ (but treated as trusted) │
│ ↕ │
│ Flash Loan Manipulation ← ADVERSARIAL │
└─────────────────────────────────────────────┘
Any on-chain value that can be atomically changed within the same transaction is, by definition, manipulable by flash loans.
Defense Patterns That Actually Work
1. TWAP Oracles (Time-Weighted Average Prices)
Instead of reading spot values, use time-weighted averages that span multiple blocks:
// Resistant to single-transaction manipulation
function getPrice() external view returns (uint256) {
(int56[] memory tickCumulatives, ) = pool.observe(
[uint32(TWAP_WINDOW), 0] // e.g., 30 minutes
);
int56 tickDelta = tickCumulatives[1] - tickCumulatives[0];
return computePriceFromTick(tickDelta / int56(uint56(TWAP_WINDOW)));
}
Flash loans exist within a single transaction (single block). A TWAP spanning 30+ minutes is immune to single-block manipulation.
2. Multi-Source Oracle Aggregation
Don't rely on a single price source. Cross-reference:
function validatePrice(uint256 price) internal view returns (bool) {
uint256 chainlinkPrice = getChainlinkPrice();
uint256 twapPrice = getTWAPPrice();
uint256 maxDeviation = 200; // 2% in basis points
require(
withinDeviation(price, chainlinkPrice, maxDeviation) &&
withinDeviation(price, twapPrice, maxDeviation),
"Price deviation too high"
);
return true;
}
3. Flash-Loan Detection Guards
Block flash-loan-powered calls to sensitive functions:
modifier noFlashLoan() {
require(
block.number > lastInteractionBlock[msg.sender],
"Same-block interaction prohibited"
);
lastInteractionBlock[msg.sender] = block.number;
_;
}
This forces at least one block to pass between deposit and withdrawal, breaking the atomic execution that flash loans require.
4. Rate-Limited AUM Updates
Instead of computing AUM on-the-fly from external state, use a delayed update pattern:
uint256 public lastAUM;
uint256 public lastAUMUpdate;
uint256 public constant AUM_UPDATE_DELAY = 1 hours;
function updateAUM() external {
require(
block.timestamp >= lastAUMUpdate + AUM_UPDATE_DELAY,
"AUM update cooldown"
);
lastAUM = computeAUM();
lastAUMUpdate = block.timestamp;
}
function getSharePrice() public view returns (uint256) {
return lastAUM / totalShares; // Uses cached, not live data
}
5. Withdrawal Caps and Circuit Breakers
Limit the damage surface even if manipulation succeeds:
modifier withdrawalCap(uint256 amount) {
uint256 maxWithdraw = totalReserves * MAX_WITHDRAW_PCT / 10000;
require(amount <= maxWithdraw, "Exceeds withdrawal cap");
_;
}
A Broader Pattern: The Composability Tax
DeFi's strength—permissionless composability—is also its attack surface. Every external call is a potential trust boundary violation. The Makina exploit joins a growing list of attacks that weaponize composability:
| Protocol | Date | Loss | External Dependency Exploited |
|---|---|---|---|
| Makina Finance | Jan 2026 | $4M | Curve pool balances |
| Resupply | Jun 2025 | $9.6M | Unverified pool collateral pricing |
| New Gold | Sep 2025 | $2M | Price oracle + transfer logic |
| KiloEx | Mar 2025 | $7M | Price feed manipulation |
The pattern is consistent: protocol A trusts protocol B's current state, attacker manipulates protocol B's state within the same transaction.
Checklist for Developers
Before shipping any contract that reads external state:
- [ ] Identify all external reads — map every
viewcall to external contracts - [ ] Classify each as spot or delayed — spot values are manipulable
- [ ] Replace spot reads with TWAPs where price/value data is involved
- [ ] Add cross-source validation — never trust a single oracle
- [ ] Implement same-block interaction guards on sensitive functions
- [ ] Add circuit breakers — pause on anomalous AUM/price changes
- [ ] Rate-limit AUM recalculations — don't compute from live state on every call
- [ ] Fuzz test with flash loan scenarios — simulate atomic manipulation in tests
- [ ] Get audited — specifically ask auditors to test flash loan vectors
Final Thought
The Makina exploit wasn't sophisticated in concept—it's the same "manipulate oracle → extract value" pattern we've seen since 2020. What's changed is the scale ($280M in borrowed capital) and the indirection (manipulating external pools that the target protocol reads from).
Every view function call to an external contract is an implicit oracle. Treat it accordingly.
This analysis is based on publicly available post-mortem reports and on-chain data. It is provided for educational purposes to improve DeFi security practices.
Top comments (0)