DEV Community

ohmygod
ohmygod

Posted on

Flash Loan Oracle Defense Patterns: What Every DeFi Developer Should Learn From Makina Finance's $4.2M Exploit

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 view function 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"
    );
}
Enter fullscreen mode Exit fullscreen mode

The Broader Pattern

Every major flash loan exploit in 2026 follows the same three-stage structure:

  1. Borrow — acquire temporary capital (flash loan, no cost)
  2. Distort — manipulate a pricing input the protocol trusts
  3. 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


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)