2021年5月20日,币安智能链DeFi收益聚合器PancakeBunny(BUNNY)遭到闪电贷攻击,发生代币闪崩事件。

此次事件共损失114,631枚BNB和697,245枚BUNNY,按当时价格计算约合约4200万美元。

仔细阅读网上多个安全团队的攻击事件分析并复盘有关漏洞代码后,我了解到这次攻击事件的核心原因在于PancakeBunny在铸造给用户质押奖励的Bunny代币时,以Pancakeswap V1的实时BNB-BUNNY的LP Token的价值进行计算,而Pancakeswap V1由于已被废弃,资产数目较少,因而该价值受到攻击者从闪电贷借出的大量资产的操纵。同时攻击者通过将LP Token预留在BNB-USDT池中的技巧,扩大了LP Token的数量,导致PancakeBunny的合约铸造了约600多万Bunny给攻击者,攻击者在Pancakeswap上瞬间卖出所有Bunny后导致代币价格崩溃。

下面将结合CertiK、慢雾安全、创宇区块链安全实验室的有关分析报告剖析攻击者的攻击过程和有关漏洞代码。

本文原创,未经许可禁止转载。

攻击原理

PancakeBunny是币安智能链上有名的收益聚合器,其质押收益的一部分会由项目方发行的BUNNY代币支付。BUNNY代币的总量无限。当从PancakeBunny取回我们质押资产的收益时,会有30%收益作为 Performance Fee 被注入奖励池奖励给其他的BUNNY的持有者,奖励池的保存方式是将Performance Fee转换为BNB-BUNNY的LP Token储存。同时,合约会根据产生的LP Token的价值铸造对应量的BUNNY代币奖励给我们。

而攻击者正是操控了Performance Fee转换产生的LP Token数目和价值,导致铸造的BUNNY代币数量异常的多。

攻击过程

质押

交易1:https://bscscan.com/tx/0x88fcffc3256faac76cde4bbd0df6ea3603b1438a5a0409b2e2b91e7c2ba3371a

首先,攻击者将1个BNB,利用PancakeBunny提供的Zap功能(即将一种代币转换为两种代币兑换池的LP),转换为了USDT-BNB FLIP,并将其质押在了PancakeBunny的对应质押合约中。

闪电贷

交易2:https://bscscan.com/tx/0x897c2de73dd55d7701e1b69ffb3a17b0f4801ced88b0c75fe1551c5fcce6a979

为了操控资产价格,攻击者首先从7个不同的PancakeSwap流动性池中利用闪电贷共借了232万BNB,从ForTube用闪电贷款借了296万USDT。

压低BNB价格

由于后面Performance Fee转换为BNB-BUNNY的LP Token储存,因此USDT-BNB池子的LP Token兑换产生的USDT需要被转换为BNB。这里的转换使用的是Pancakeswap V1的BNB-USDT流动性池,由于V1的流动性不足,因此攻击者很容易操纵其中的BNB价格,这也是本次攻击能成功的重要原因。

攻击者将从闪电贷中获得的232万BNB在PancakeSwap V1池中换取了383万USDT,这导致BNB的价格急剧下降。

将LP Token留在池子里

攻击者向Pancakeswap V2的 USDT-BNB 池提供了7700枚BNB和296万USDT的流动性,获得了14.4万LP代币,但攻击者并没有拿走这些代币,而是将其存在了Pancakeswap V2的 USDT-BNB 池的合约中,至于为什么这么做,就需要分析下Pancakeswap的具体代码实现了。

合约地址:https://bscscan.com/address/0x16b9a82891338f9bA80E2D6970FddA79D1eb0daE#code

①添加流动性

添加流动性时,Pancakeswap的前端是调用了Router合约中的addLiquidity函数,其具体实现如下:

function addLiquidity(
address tokenA,
address tokenB,
uint amountADesired,
uint amountBDesired,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) external virtual override ensure(deadline) returns (uint amountA, uint amountB, uint liquidity) {
(amountA, amountB) = _addLiquidity(tokenA, tokenB, amountADesired, amountBDesired, amountAMin, amountBMin);
address pair = PancakeLibrary.pairFor(factory, tokenA, tokenB);
TransferHelper.safeTransferFrom(tokenA, msg.sender, pair, amountA);
TransferHelper.safeTransferFrom(tokenB, msg.sender, pair, amountB);
liquidity = IPancakePair(pair).mint(to);
}

可以看到,添加流动性的基本原理是通过_addLiquidity函数计算出真实的添加数目,然后直接把对应的Token转给流动性池合约,最后调用流动性池合约的mint函数,该函数的具体实现如下:

function mint(address to) external lock returns (uint liquidity) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
uint balance0 = IERC20(token0).balanceOf(address(this));
uint balance1 = IERC20(token1).balanceOf(address(this));
uint amount0 = balance0.sub(_reserve0);
uint amount1 = balance1.sub(_reserve1);

bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
if (_totalSupply == 0) {
liquidity = Math.sqrt(amount0.mul(amount1)).sub(MINIMUM_LIQUIDITY);
_mint(address(0), MINIMUM_LIQUIDITY); // permanently lock the first MINIMUM_LIQUIDITY tokens
} else {
liquidity = Math.min(amount0.mul(_totalSupply) / _reserve0, amount1.mul(_totalSupply) / _reserve1);
}
require(liquidity > 0, 'Pancake: INSUFFICIENT_LIQUIDITY_MINTED');
_mint(to, liquidity);

_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
emit Mint(msg.sender, amount0, amount1);
}

分析上述函数的功能可知,该函数根据目前合约中比之前多出来的代币数目,铸造对应的LP Token转给传入的to地址,而攻击者正是在这里填入了BNB-USDT流动性池的合约地址

②移除流动性

移除流动性时,Pancakeswap的前端是调用了Router合约中的removeLiquidity函数,实现如下:

function removeLiquidity(
address tokenA,
address tokenB,
uint liquidity,
uint amountAMin,
uint amountBMin,
address to,
uint deadline
) public virtual override ensure(deadline) returns (uint amountA, uint amountB) {
address pair = PancakeLibrary.pairFor(factory, tokenA, tokenB);
IPancakePair(pair).transferFrom(msg.sender, pair, liquidity); // send liquidity to pair
(uint amount0, uint amount1) = IPancakePair(pair).burn(to);
(address token0,) = PancakeLibrary.sortTokens(tokenA, tokenB);
(amountA, amountB) = tokenA == token0 ? (amount0, amount1) : (amount1, amount0);
require(amountA >= amountAMin, 'PancakeRouter: INSUFFICIENT_A_AMOUNT');
require(amountB >= amountBMin, 'PancakeRouter: INSUFFICIENT_B_AMOUNT');
}

可以看到,Router首先将用户要销毁的LP Token转给了对应的流动性池,然后直接调用流动性池的Burn函数,该函数实现如下:

function burn(address to) external lock returns (uint amount0, uint amount1) {
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
address _token0 = token0; // gas savings
address _token1 = token1; // gas savings
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
uint liquidity = balanceOf[address(this)];

bool feeOn = _mintFee(_reserve0, _reserve1);
uint _totalSupply = totalSupply; // gas savings, must be defined here since totalSupply can update in _mintFee
amount0 = liquidity.mul(balance0) / _totalSupply; // using balances ensures pro-rata distribution
amount1 = liquidity.mul(balance1) / _totalSupply; // using balances ensures pro-rata distribution
require(amount0 > 0 && amount1 > 0, 'Pancake: INSUFFICIENT_LIQUIDITY_BURNED');
_burn(address(this), liquidity);
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));

_update(balance0, balance1, _reserve0, _reserve1);
if (feeOn) kLast = uint(reserve0).mul(reserve1); // reserve0 and reserve1 are up-to-date
emit Burn(msg.sender, amount0, amount1, to);
}

burn函数会获取当前合约的LP Token余额,并将所有余额销毁,换成对应的两种代币转给调用者。因此,如果攻击者提前将LP Token留在流动性池的合约中,那么当下一个用户调用移除流动性时,就会获得攻击者留下的LP Token对应的代币

这也就是攻击者将LP Token留在流动性池合约中的原因。

从Bunny获取奖励

获取奖励会调用PancakeBunny的VaultFlipToFlip合约,该合约地址为:https://bscscan.com/address/0xd415e6caa8af7cc17b7abd872a42d5f2c90838ea#code

涉及到的是其中的getReward函数,内容如下:

function getReward() external override {
uint amount = earned(msg.sender);
uint shares = Math.min(amount.mul(totalShares).div(balance()), _shares[msg.sender]);
totalShares = totalShares.sub(shares);
_shares[msg.sender] = _shares[msg.sender].sub(shares);
_cleanupIfDustShares();

amount = _withdrawTokenWithCorrection(amount);
uint depositTimestamp = _depositedAt[msg.sender];
uint performanceFee = canMint() ? _minter.performanceFee(amount) : 0;
if (performanceFee > DUST) {
_minter.mintForV2(address(_stakingToken), 0, performanceFee, msg.sender, depositTimestamp);
amount = amount.sub(performanceFee);
}

_stakingToken.safeTransfer(msg.sender, amount);
emit ProfitPaid(msg.sender, amount, performanceFee);
}

可以看到,该函数首先计算了当前已经获得的收益,即amount,然后计算了要抽取的Performance Fee,如果Performance Fee大于1000,则调用BUNNYMinterV2合约中的mintForV2函数来转换Performance Fee和铸造Bunny。

BunnyMinterV2合约地址:https://bscscan.com/address/0x819eea71d3f93bb604816f1797d4828c90219b5d#code

mintForV2函数内容如下:

function mintForV2(address asset, uint _withdrawalFee, uint _performanceFee, address to, uint) external payable override onlyMinter {
uint feeSum = _performanceFee.add(_withdrawalFee);
_transferAsset(asset, feeSum);

if (asset == BUNNY) {
IBEP20(BUNNY).safeTransfer(DEAD, feeSum);
return;
}

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);
uint contribution = valueInBNB.mul(_performanceFee).div(feeSum);
uint mintBunny = amountBunnyToMint(contribution);
if (mintBunny == 0) return;
_mint(mintBunny, to);
}

可以看到,该函数首先使用_zapAssetsToBunnyBNB将Performance Fee转换为Bunny-BNB的LP Token,并返回产生Token的数目。这个转换过程首先是将Performance Fee对应数目的LP Token转给BNB-USDT流动性池,取回流动性。但由于攻击者在上一步在BNB-USDT池中预留的大量LP Token,所以这里取回的BNB和USDT会远远大于正常值。从池中取出了296万USDT和7744枚BNB。

接下来Zap会把296万USDT都换成BNB,但是Zap没有升级到Pancakeswap V2,因此他会在V1的BNB-USDT池中进行兑换,但上面攻击者已经在V1的池中压低BNB价格,因此这296万的USDT足足换到了231万BNB。接下来一半的BNB,也就是115.6万BNB被在BUNNY-BNB的流动性池中换成了BUNNY,由于BUNNY-BNB的流动性池的资产储备也不高,这一换导致BNB的数目大量增加。最后生成了大量的BUNNY-BNB的LP Token,也就是bunnyBNBAmount变量的值。

完成兑换后,上述代码继续执行,接下来会调用priceCalculatorvalueOfAsset计算BUNNY-BNB的价值,并将其作为后面调用amountBunnyToMint铸造BUNNY的contribution的重要依据。

priceCalculator的对应代码片段如下:

valueInBNB = amount.mul(IBEP20(WBNB).balanceOf(address(asset))).mul(2).div(IPancakePair(asset).totalSupply());

可以看到,如果池中有一个资产为BNB时,priceCalculator会以BNB的数目为基准计算资产价格,但是刚刚攻击者让BUNNY-BNB池中的BNB数目大量增加,也就是BNB变得不值钱了,因此BNB-BUNNY的LP Token相对BNB来讲就变得很值钱,这里的valueInBNB就变得非常大

而式中的amount则是刚刚的bunnyBNBAmount变量的值,这个值也受攻击者操控,变得非常大!因而最后产生的contribution也是一个非常大的值。

攻击者总共操控合约铸造了大约600多万的Bunny,随后攻击者将得到的Bunny在Pancakeswap V1和V2的流动性池中全部兑换为BNB和USDT,用来归还闪电贷,结束攻击。这样的兑换同时也造成了Bunny价格崩溃,从200多美元暴跌至1美元,险些归零。

反思

事后,PancakeBunny项目团队很快停止了资产的质押和解除质押,并通过Chainlink价格预言机、不再将资产转换为BUNNY-BNB的LP Token等形式修复了漏洞。同时,他们也承诺会赔付用户BUNNY的价值损失。有幸的是,这是一场针对BUNNY代币的攻击,没有危害到PancakeBunny上质押资产的安全。

这场攻击之所以成功的核心原因在于PancakeBunny没有使用平均价格(如Alpha Finance 团队提出的计算方式)或者价格预言机来计算BNB价格,而是仅仅采用了V1池BUNNY-BNB中的BNB数目来计算资产相对于BNB的价格,极易被攻击者操控。同时,在BunnyMinterV2中使用的ZapBSC没有及时升级到ZapBSCV2也是攻击者能成功攻击的原因之一,当然这不是必须的因素,攻击者可以通过其他的来操控V1池BUNNY-BNB中的BNB数目达到目的,只是现在这样的资产利用率会最高。

更多的DeFi项目应该从这场攻击中吸取教训,谨慎选择资产价值估算方式,并警惕老版本流动性池流动性降低带来的安全性威胁。

参考资料

[1] 慢雾简析 PancakeBunny 被黑:由 WBNB-BUNNY LP 价格计算存在缺陷被攻击者利用所致 https://www.sosob.com/hot/36813.html

[2] 首发 | PancakeBunny闪崩事件最全技术细节剖析 https://www.jinse.com/news/blockchain/1097297.html

[3] 币安智能链 PancakeBunny 攻击事件分析 https://www.sohu.com/a/467617243_100217347