BSC PancakeBunny Exploit Post Mortem

Categories:

On May 19th 2021, PancakeBunny was exploited by an attacker abusing a wrong PancakeSwap LP price computation in Bunny’s PriceCalculatorBSCV1 contract to mint 6.97M BUNNY tokens which were then exchanged for a profit of 114,631 WBNB (~30M USD).

Background

PancakeBunny is a yield aggregator accepting a variety of tokens, among them LP tokens from PancakeSwap. Stakers need to pay a 30% performance fee on the profits when withdrawing/claiming. However, they also receive BUNNY tokens in return - for every 1 BNB in fees collected, 3 BUNNY is rewarded to the depositor.

The Exploit

There’s an official post mortem but it lacks depth making it hard to understand. We will provide more details for the series of events:

  1. The attacker deploys a contract, already acquires 9.2751 WBNB<>BUSD-T PancakeSwap LP tokens (380$), and deposits them to the VaultFlipToFlip contract in this first transaction.
  2. Attacker takes a flashloan
  3. Mints 144,445.5921 WBNB<>BUSDT v2 LP tokens to the pair contract itself. (The BunnyMinter will later receive these LP tokens when it calls the router.removeLiquidity function.)
  4. Swaps 2,315,631 WBNB to 3,826,047 BUSDT in the different lower liquidity PancakeSwap v1 USDT/BNB pool, significantly increasing the WBNB in the pool’s reserves.
  5. Attacker withdraws the “profit” of the staked LP tokens from 1) by calling VaultFlipToFlip.getReward() plus 6,972,455 minted BUNNY tokens. The minting of large amounts of BUNNY tokens on the performance fee is due to a wrong LP price calculation.
  6. Trades the BUNNY tokens to WBNB.
  7. Repays all flashloans.

Most of the attack happens in step 5) and it’s best understood by looking at the code.

The entry-point is the call to the VaultFlipToFlip.getReward function which computes the performance fee on the small amount of LP tokens provided in step 1) and then calls BunnyMinter.mintForV2(to=attacker).

// VaultFlipToFlip 0xd415e6caa8af7cc17b7abd872a42d5f2c90838ea
function getReward() external override {
    uint amount = earned(msg.sender); // returns 0.000521785526032378e18
    // ...

    amount = _withdrawTokenWithCorrection(amount); // withdraws same amount from MasterChef
    uint depositTimestamp = _depositedAt[msg.sender];
    uint performanceFee = canMint() ? _minter.performanceFee(amount) : 0; // 30% of earned
    if (performanceFee > DUST) {
        // important call that internally mints the BUNNY tokens
        _minter.mintForV2(address(_stakingToken), 0, performanceFee, msg.sender, depositTimestamp);
        amount = amount.sub(performanceFee);
    }

    _stakingToken.safeTransfer(msg.sender, amount); // withdraws 70% of earned LP tokens
}

function balance() public view override returns (uint amount) {
    (amount,) = CAKE_MASTER_CHEF.userInfo(pid, address(this)); // total LP token balance of all depositors
}
function balanceOf(address account) public view override returns(uint) {
    if (totalShares == 0) return 0;
    return balance().mul(sharesOf(account)).div(totalShares);
}
function earned(address account) public view override returns (uint) {
    if (balanceOf(account) >= principalOf(account) + DUST) {
        return balanceOf(account).sub(principalOf(account));
    } else {
        return 0;
    }
}

The _performanceFee amount determines the amount of BUNNY tokens to mint for the attacker (3 BUNNY per 1 WBNB) in mintForV2. But even with the manipulated PancakeSwap spot price, it would not make this attack profitable. Another amplifier is needed and this is where the LP provisioning from step 3) comes in: The fees (denoted in WBNB <> BUSDT LP tokens) are first traded for WBNB <> BUNNY LP tokens using the BunnyMinter._zapAssetsToBunnyBNB function which:

  1. First calls pancakeRouter.removeLiquidity() with the fee amount. Internally, the router calls the pair.burn function which burns the whole LP token balance of the contract. This includes the 144,445 LP tokens from step 3), returning large amounts of WBNB and BUSDT to the BunnyMinter.
  2. It then swaps these amounts to provide liquidity for the WBNB <> BUNNY pair. This swap happens in the manipulated v1 WBNB <> BUSDT PancakeSwap pair. It then returns the minted LP tokens.
// BunnyMinterV2.sol 0x819eea71d3f93bb604816f1797d4828c90219b5d
function mintForV2(address asset /* LP token */, uint _withdrawalFee /* 0 */, uint _performanceFee /* 0.00015... */, address to /* attacker */, uint) external payable override onlyMinter {
    uint feeSum = _performanceFee.add(_withdrawalFee);
    _transferAsset(asset, feeSum); // transfers LP tokens from VaultFlipToFlip to this

    // removes liquidity from WBNB <> BUSDT pool. Because of previously minted LPs returns
    // 2,961,750 USDT and 7,744 WBNB
    // then swaps these to WBNB and BUNNY using the manipulated v1 pool
    // and provides liquidity to the WBNB <> BUNNY pool returns these LP tokens as bunnyBNBAmount
    uint bunnyBNBAmount = _zapAssetsToBunnyBNB(asset, feeSum, true);

    if (bunnyBNBAmount == 0) return;

    IBEP20(BUNNY_BNB).safeTransfer(BUNNY_POOL, bunnyBNBAmount);
    IStakingRewards(BUNNY_POOL).notifyRewardAmount(bunnyBNBAmount);

    (uint valueInBNB,) = priceCalculator.valueOfAsset(BUNNY_BNB, bunnyBNBAmount); // returns inflated value
    uint contribution = valueInBNB.mul(_performanceFee).div(feeSum);
    uint mintBunny = amountBunnyToMint(contribution); // multiplies by 3 (1 WBNB : 3 BUNNY)
    if (mintBunny == 0) return;
    _mint(mintBunny, to); // mints BUNNY for attacker
}

Finally, the already high amount of WBNB <> BUNNY LP tokens are multiplied by a manipulated price in priceCalculator.valueOfAsset(BUNNY_BNB, bunnyBNBAmount).

// PriceCalculatorBSCV1.sol 0x81ef2bc1e02fee5414e46accc6ae14d833eebba0
function valueOfAsset(address asset, uint amount) public view override returns (uint valueInBNB, uint valueInUSD) {
    if (keccak256(abi.encodePacked(IPancakePair(asset).symbol())) == keccak256("Cake-LP")) {
        (uint reserve0, uint reserve1, ) = IPancakePair(asset).getReserves();
        if (IPancakePair(asset).token0() == WBNB) {
            valueInBNB = amount.mul(reserve0).mul(2).div(IPancakePair(asset).totalSupply());
            valueInUSD = valueInBNB.mul(priceOfBNB()).div(1e18);
        } else if (IPancakePair(asset).token1() == WBNB) {
            valueInBNB = amount.mul(reserve1).mul(2).div(IPancakePair(asset).totalSupply());
            valueInUSD = valueInBNB.mul(priceOfBNB()).div(1e18);
        } else {
            // ... recursion on both, not relevant
        }
    }
}

The TVL of the pool is computed as two times the WBNB reserves which have been inflated in step 2). This does not work and has already been used in the warp.finance hack.

The inflated value is then used to mint BUNNY tokens for the attacker.

Hi, I'm Christoph Michel 👋

I'm a , , and .

Currently, I mostly work in software security and do on an independent contractor basis.

I strive for efficiency and therefore track many aspects of my life.