/// @notice This function burn V2 liquidity, and mint V3 liquidity with received tokens
/// @param params see IV3Migrator.MigrateParams
/// @return refund0 amount of token0 that burned from V2 but not used to mint V3 liquidity
/// @return refund1 amount of token1 that burned from V2 but not used to mint V3 liquidity
function migrate(MigrateParams calldata params) external override returns(uint refund0, uint refund1){
// burn v2 liquidity to this address
IBiswapPair(params.pair).transferFrom(params.recipient, params.pair, params.liquidityToMigrate);
(uint256 amount0V2, uint256 amount1V2) = IBiswapPair(params.pair).burn(address(this));
// calculate the amounts to migrate to v3
uint128 amount0V2ToMigrate = uint128(amount0V2);
uint128 amount1V2ToMigrate = uint128(amount1V2);
// approve the position manager up to the maximum token amounts
safeApprove(params.token0, liquidityManager, amount0V2ToMigrate);
safeApprove(params.token1, liquidityManager, amount1V2ToMigrate);
// mint v3 position
(, , uint256 amount0V3, uint256 amount1V3) = ILiquidityManager(liquidityManager).mint(
ILiquidityManager.MintParam({
miner: params.recipient,
tokenX: params.token0,
tokenY: params.token1,
fee: params.fee,
pl: params.tickLower,
pr: params.tickUpper,
xLim: amount0V2ToMigrate,
yLim: amount1V2ToMigrate,
amountXMin: params.amount0Min,
amountYMin: params.amount1Min,
deadline: params.deadline
})
);
// if necessary, clear allowance and refund dust
if (amount0V3 < amount0V2) {
if (amount0V3 < amount0V2ToMigrate) {
safeApprove(params.token0, liquidityManager, 0);
}
refund0 = amount0V2 - amount0V3;
if (params.refundAsETH && params.token0 == WETH9) {
IWETH9(WETH9).withdraw(refund0);
safeTransferETH(params.recipient, refund0);
} else {
safeTransfer(params.token0, params.recipient, refund0);
}
}
if (amount1V3 < amount1V2) {
if (amount1V3 < amount1V2ToMigrate) {
safeApprove(params.token1, liquidityManager, 0);
}
refund1 = amount1V2 - amount1V3;
if (params.refundAsETH && params.token1 == WETH9) {
IWETH9(WETH9).withdraw(refund1);
safeTransferETH(params.recipient, refund1);
} else {
safeTransfer(params.token1, params.recipient, refund1);
}
}
emit Migrate(
params,
amount0V2,
amount1V2,
amount0V3,
amount1V3
);
}
可以发现该函数可以被外部任意调用,且params参数也基本没有验证。
migrate函数,在函数中调用transferFrom,其中的pair为真实的pair合约,recipient为受害者的外部地址,数量为之前获取的授权数量。这里将受害者地址的所有lp发送migrate合约。之后再由migrate合约burn掉所有lp,将两种真实代币由pair合约发送本migrate合约:
// burn v2 liquidity to this address
IBiswapPair(params.pair).transferFrom(params.recipient, params.pair, params.liquidityToMigrate);
(uint256 amount0V2, uint256 amount1V2) = IBiswapPair(params.pair).burn(address(this));
pair:0x46492b26639df0cda9b2769429845cb991591e0a (BiswapPair)(BSW)(WBNB)
recipient:0x2978d920a1655abaa315bad5baf48a2d89792618(Victim EOA)
liquidityToMigrate:2167939368021752115474
之后的逻辑是migrate合约把从V2版本pair合约发送来的两种token授权给liquidityManager,但是token0和token1被黑客指定为假的地址,所以两种代币实际上是被锁在了本合约。
// approve the position manager up to the maximum token amounts
safeApprove(params.token0, liquidityManager, amount0V2ToMigrate);
safeApprove(params.token1, liquidityManager, amount1V2ToMigrate);
mint V3版本的LP,mint到受害者地址,但两种代币都是假的,所以受害者获取到的LP也是假的。
// mint v3 position
(, , uint256 amount0V3, uint256 amount1V3) = ILiquidityManager(liquidityManager).mint(
ILiquidityManager.MintParam({
miner: params.recipient,
tokenX: params.token0,
tokenY: params.token1,
fee: params.fee,
pl: params.tickLower,
pr: params.tickUpper,
xLim: amount0V2ToMigrate,
yLim: amount1V2ToMigrate,
amountXMin: params.amount0Min,
amountYMin: params.amount1Min,
deadline: params.deadline
})
);
tokenX:0x6919b2988d68128ed62644d7043c1799dd0f0d78(fake token 1)
tokenY:0xe26ade3a97f068603af72690746bae81b971d4f9(fake token 2)
第二次调用migrate:
这里的pair合约地址是攻击合约。
IBiswapPair(params.pair).transferFrom(params.recipient, params.pair, params.liquidityToMigrate);
(uint256 amount0V2, uint256 amount1V2) = IBiswapPair(params.pair).burn(address(this));
pair:0x76a40bd16b6d2b9bfbfe112199bae836c16dfc84(攻击合约)
Recipient:0xa7a98876c1dc2bffc4b2c8ccdebb847ff808662b(攻击者地址)
这里用攻击合约的假burn函数返回假的两种代币数量:
这次向攻击者地址mint V3版本的LP且两种代币为真实代币。
// mint v3 position
(, , uint256 amount0V3, uint256 amount1V3) = ILiquidityManager(liquidityManager).mint(
ILiquidityManager.MintParam({
miner: params.recipient,
tokenX: params.token0,
tokenY: params.token1,
fee: params.fee,
pl: params.tickLower,
pr: params.tickUpper,
xLim: amount0V2ToMigrate,
yLim: amount1V2ToMigrate,
amountXMin: params.amount0Min,
amountYMin: params.amount1Min,
deadline: params.deadline
})
);
tokenX:0x965f527d9159dce6288a2219db51fc6eef120dd1(BSW)
tokenY:0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c(WBNB)
这次mint结果是向V3的pool合约发送了全部的BSW代币,攻击者获取了相应的V3 LP:
之后将合约中剩余的BSW和WBNB发送给攻击者地址:
-
黑客收集将LP token授权给V3Migrator合约的地址。 -
黑客创建攻击合约,该攻击合约要能够调用V3Migrator合约的migrate函数,同时也要实现transferFrom和burn接口。 -
攻击合约第一次调用migrate函数,输入真实的pair合约和两个假的token合约地址。 -
受害者的lp被转到V3Migrator合约并被销毁,销毁的LP变成交易对中的两种代币并发回给V3Migrator合约。 -
mint V3版本的LP给受害者地址,但输入的两个token是假的。 -
攻击合约第二次调用migrate函数,这时pair合约地址设为攻击合约自己,两个token地址设置成真实的token地址。 -
向黑客地址mint V3版本的LP,两种token的地址为真实地址,这时黑客获取的LP是真实的。 -
V3Migrator合约将剩余的真实token发送给黑客地址。 -
黑客调用decLiquidity函数销毁LP,获取流动性。 -
重复上述过程,遍历获取所有攻击者的流动性。
转移LP是现在DeFi项目的常见功能,biSwap的migrate函数在被调用时没有做任何权限限制,按照项目方的设想,一旦用户将LPtoken授权给V3Migrator合约,就相当于同意进行转移操作,但在实际的实现上,最好对调用者进行限定。
其次是对输入参数没有做任何检查,transferFrom和burn函数的目标地址可以被任意指定,一方面这两个函数会进行一些余额判断,在条件不满足时会revert,如果使用假的地址,则会绕过本应该在逻辑中的判断。另一方面,由于可以任意指定地址,所以可以在函数中再对目标合约进行重入。这两种类型的漏洞都会对交易的原子性产生破坏。
两种token的真伪也没有验证,假的token可以将本应该转移到V3版本池子的代币锁在本合约中,mint给受害者的LP也是假的。正确做法是先验证pair合约是否合法,并通过这个pair合约获取两种代币地址,之后mint新版本的LP时就使用这个地址。
原文始发于微信公众号(山石网科安全技术研究院):Biswap交易所攻击事件分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论