{%hackmd @themes/orangeheart %} # KyberSwap Attack Analysis  # **Introduction** Để cho phép các nhà cung cấp thanh khoản (LPs) cung cấp thanh khoản trong các khoảng giá tùy chỉnh, KyberSwap đã áp dụng một phương pháp tương tự như **Uniswap V3**, trong đó không gian giá tiềm năng được chia thành các đơn vị rời rạc gọi là **"Ticks"**. LPs có thể cung cấp thanh khoản giữa bất kỳ hai Tick nào. Trong quá trình giao dịch swap, nếu giá di chuyển vượt ra ngoài phạm vi giá của Tick hiện tại, hệ thống sẽ chuyển sang Tick tiếp theo và tiếp tục hoán đổi ở mức giá mới cho đến khi: - Đáp ứng đủ số lượng hoán đổi mà người dùng yêu cầu, hoặc - Chạm đến giới hạn giá mà người dùng đặt trước(slippage limit). Trong thiết kế của Kyber, việc chuyển đổi Tick sẽ làm thay đổi thanh khoản của pool, một quá trình mà Kyber gọi là **"Cross Ticks"**. Cuộc tấn công mà KyberSwap phải đối mặt có liên quan chặt chẽ đến **cơ chế "Cross Tick" này**. Để minh họa rõ hơn quá trình **Crossing Ticks**, hãy xem xét một ví dụ: ![image](https://hackmd.io/_uploads/H1guqSgCke.png) Trong sơ đồ minh họa phía trên, pool có **ba vị thế thanh khoản**: - **Vị thế 1** (màu cam) có phạm vi giá từ **T1 - T6**. - **Vị thế 2** (màu xanh) có phạm vi giá từ **T4 - T8**. - **Vị thế 3** (màu xanh lá) có phạm vi giá từ **T5 - T10**. Giả sử Tick hiện tại của pool là T5. Ở mức giá này, thanh khoản tổng hợp từ **cả ba vị thế (1, 2, 3)** tạo thành **tổng thanh khoản là 500**. Nếu có một giao dịch swap lớn khiến giá vượt qua Tick T5 và giảm xuống phạm vi của Tick T4, thì pool sẽ thực hiện **Crossing Ticks**. Do Tick T4 nằm ngoài phạm vi giá của Vị thế 3, nên thanh khoản của Vị thế 3 sẽ bị loại bỏ khỏi pool. Hậu quả là tổng thanh khoản của pool sẽ giảm xuống còn 400. Tương tự, giả sử Tick hiện tại (currentTick) là T4, và có một giao dịch swap ngược xảy ra, khiến pool Cross Tick từ T4 sang T5. Khi giá di chuyển vào phạm vi của Vị thế 3, thanh khoản của Vị thế 3 sẽ được thêm vào pool, làm tăng tổng thanh khoản lên 100. Do đó, tổng thanh khoản của pool sẽ thay đổi từ 400 lên 500. > Cũng cần lưu ý rằng lỗ hổng trong cuộc tấn công này chỉ tồn tại trong **KyberSwap Elastic**. Các giao thức thanh khoản tập trung khác như **Uniswap** hoặc **Ambient** không gặp phải lỗ hổng này. # **Qui trình tấn công** **Giao dịch tấn công:** https://etherscan.io/tx/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3 **Địa chỉ của kẻ tấn công:** `0x50275E0B7261559cE1644014d4b78D4AA63BE836` **Hợp đồng tấn công:** `0xaF2Acf3D4ab78e4c702256D214a3189A874CDC13` **Hợp đồng KyberSwap (hợp đồng bị lỗ hổng):** `0xfd7b111aa83b9b6f547e617c7601efd997f64703` ### **1. Flashloans** Đầu tiên, hacker vay **2,000 WETH** từ Aave thông qua một flashloan và đưa ra yêu cầu swap vào bể thanh khoản, với việc yêu cầu swap toàn bộ 2,000 WETH và đặt một **giới hạn trượt giá** (slippage limit), làm di chuyển tick của bể thanh khoản từ **-24 đến 110909**. Mục đích ở đây là di chuyển giá qua một tick với **thanh khoản bằng 0**. ![image](https://hackmd.io/_uploads/rJZmozZ01g.png) ### **2. Cung cấp và rút thanh khoản** Hacker sau đó đã cung cấp thanh khoản bao gồm **0.0069 frxETH và 0.1078 WETH** trong phạm vi tick từ **110909 đến 111310**, và sau đó ngay lập tức đốt một phần thanh khoản, chỉ để lại **0.0058 frxETH và 0.01 WETH** trong phạm vi đó. Mục đích của việc này là điều chỉnh các giá trị sao cho các phép tính sử dụng trong lần swap tiếp theo là chính xác (sẽ giải thích chi tiết hơn sau). ### 3. Giao dịch swap đầu tiên Kẻ tấn công sau đó đã thực hiện hai giao dịch swap qua lại tại mức giá này. Vì hacker là nhà cung cấp thanh khoản **duy nhất** và không có thanh khoản nào khác trong phạm vi tick, hai giao dịch swap này ban đầu chỉ là các giao dịch qua lại với chính thanh khoản của chính hacker. Giao dịch swap đầu tiên (Swap 1) liên quan đến việc lấy **387.17 WETH** và mua **0.00579 frxETH**, điều này đã đưa giá của bể thanh khoản lên đến giới hạn trên của phạm vi tick tại `111310` và biến toàn bộ thanh khoản thành WETH. ### 4. Giao dịch swap thứ 2 Giao dịch swap thứ hai (Swap 2) là ngược lại, với **0.00586 frxETH** để mua **396.24 WETH**. Điều này đã di chuyển giá của bể thanh khoản xuống tick `111105`, một tick trong phạm vi thanh khoản mà kẻ tấn công đã cung cấp. Trong bước này, thanh khoản bị tính đúp (double-counted) để làm cho việc hoán đổi có lời, từ đó rút cạn thanh khoản trong pool. Cuối cùng, trả nợ cho khoản vay (Flashloans), và thu hoạch **6.364 WETH** và **1.117 frxETH**. # Phân tích chi tiết > Source code để phân tích: https://etherscan.deth.net/address/0xFd7B111AA83b9b6F547E617C7601EfD997F64703 > ## Thao túng tick hiện tại và currentSqrtP Mấu chốt của cuộc tấn công là khai thác vào cách tính thanh khoản trong mã nguồn của KyberSwap để lừa bể thanh khoản nghĩ rằng nó có **gấp đôi** lượng thanh khoản so với thực tế. Điểm đầu tiên có thể khai thác trong source code: ```solidity // continue swapping while specified input/output isn't satisfied or price limit not reached while (swapData.specifiedAmount != 0 && swapData.sqrtP != limitSqrtP) { // ... // 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 ); } ``` Giao dịch swap được thực hiện trong một vòng lặp **`while`** cho đến khi một lượng thanh khoản (liquidity) xác định được sử dụng hết hoặc một giới hạn xác định được đạt đến. Nếu thanh khoản cho phạm vi **tick** hiện tại đã được sử dụng hết, hàm `_updateLiquidityAndCrossTick` sẽ được gọi ở cuối mã trên để truy xuất thanh khoản cho **tick** tiếp theo và sau đó tiếp tục quá trình tính toán. ![image](https://hackmd.io/_uploads/r1oEY6x0kx.png) Tuy nhiên, khi câu lệnh **`break`** trong câu **`if`** ở trên được gọi, vòng lặp **`while`** sẽ ngay lập tức thoát mà không cho phép gọi hàm **`_updateLiquidityAndCrossTick`** bên dưới. ```solidity // 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; } ``` Câu lệnh **`if`** ở trên được gọi sau khi hoàn thành việc tính toán giao dịch trong một phạm vi **tick**, trong đó **`swapData.nextSqrtP`** là giá tại **tick** tiếp theo. Nội dung câu lệnh `if` có thể hiểu như sau: Hoàn thành tính toán giao dịch và kết thúc nếu như không đến được tick tiếp theo. Thông thường, cú pháp này sẽ hoạt động như mong đợi, nhưng kẻ tấn công đã làm cho giá hiện tại **`swapData.sqrtP`** vượt qua **`swapData.nextSqrtP`** đồng thời khiến vòng lặp **`while`** kết thúc và **ngăn cản** việc cập nhật thanh khoản. Điều này đã gây ra lỗi trong tính toán của pool, dẫn đến vụ tấn công. Nghĩa là attacker đã đánh lừa pool khiến nó tin rằng tick `111.310` chưa bị vượt qua. Tuy nhiên, trên thực tế, giá trị `currentSqrtP` thực sự lớn hơn `sqrtP` tại tick `111.310`. Trước khi cú pháp ở trên được gọi, hàm **`computeSwapStep`** được dùng để tính toán kết quả giao dịch trong phạm vi tick hiện tại, đầu tiên hàm `calcReachAmount` được gọi để tính toán lượng token đầu vào cần thiết nhằm đạt đến giá trị `targetSqrtP` (tick kế tiếp hoặc mức giá mục tiêu do người dùng chỉ định) ```solidity 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 ); ``` Tuy nhiên, hacker đã có thể thao túng các phép tính và thực hiện vụ tấn công theo cách sau. 1. Hacker yêu cầu một giao dịch swap ít hơn một đơn vị (`387170294533119999999999`) so với số lượng giao dịch (`387170294533120000000000`) mà sẽ đạt đến một tick mới (chi tiết sẽ được đề cập ở phần kế). 2. Hàm **`computeSwapStep`** sau đó sẽ tính toán rằng lượng thanh khoản đủ để vượt qua tick hiện tại sẽ được ghi lại. 3. Tuy nhiên, nếu tính toán giá qua **`calcFinalPrice` -** hàm tính giá đã thay đổi dựa trên giao dịch swap, ta sẽ nhận được một giá trị cao hơn **tick** hiện tại. KyberSwap có một tính năng gọi là **"tái đầu tư thanh khoản" (liquidity reinvestment)**. Uniswap V3 có nhược điểm là các khoản phí sinh ra từ pool được lưu trữ riêng biệt với thanh khoản, yêu cầu các LP phải tái đầu tư hoặc thu hồi chúng một cách thủ công, và tính năng tái đầu tư thanh khoản là một cải tiến để giải quyết vấn đề này. Do đó, các khoản phí sinh ra từ pool trong KyberSwap Elastic được lưu trữ dưới dạng thanh khoản gọi là **`reinvestL`** trong pool, cho phép các LP hưởng lợi từ việc cộng gộp (compounding). Dưới đây là nơi gặp phải vấn đề: ```solidity (usedAmount, returnedAmount, deltaL, swapData.sqrtP) = SwapMath.computeSwapStep( swapData.baseL + swapData.reinvestL, swapData.sqrtP, targetSqrtP, swapFeeUnits, swapData.specifiedAmount, swapData.isExactInput, swapData.isToken0 ); ``` Trong hàm `computeSwapStep`, đầu vào thanh khoản là `swapData.baseL` - thanh khoản tồn tại trong phạm vi tick hiện tại, cộng với `swapData.reinvestL` - thanh khoản tồn tại toàn cầu trong pool. Dựa trên thanh khoản này, `usedAmount` được tính toán và so sánh với số lượng yêu cầu, như sau: ```solidity usedAmount = calcReachAmount( liquidity, currentSqrtP, targetSqrtP, feeInFeeUnits, isExactInput, isToken0 ); if ( (isExactInput && usedAmount >= specifiedAmount) || (!isExactInput && usedAmount <= specifiedAmount) ) { usedAmount = specifiedAmount; } else { nextSqrtP = targetSqrt } ``` Nếu `swapData.reinvestL` không được thêm vào, thì `usedAmount` sẽ nhỏ hơn `specifiedAmount`, và giá trị được trả về bởi câu lệnh `else` sẽ cố định tại `targetSqrtP`, tức là `nextSqrtP`. Nói cách khác, giá trị **không thể bị thao túng** trong trường hợp này. Tuy nhiên, nếu `swapData.reinvestL` được thêm vào thanh khoản tổng, thì `usedAmount` sẽ **lớn hơn** `specifiedAmount` và câu lệnh sau trong phần điều kiện `if` sẽ được thực thi. ```solidity usedAmount = specifiedAmount; ``` `specifiedAmount` được tính toán là giá trị của thanh khoản ngay trước khi đến giá trị ngưỡng của tick `111310`, điều này có nghĩa là `usedAmount` được tính trong hàm này sẽ dẫn đến việc giao dịch không vượt qua tick `111310`. **Điểm quan trọng:** Trong `computeSwapStep`, hệ thống tính được lượng hoán đổi tối đa trong phạm vi tick hiện tại là: `387170294533120000000` Chỉ cao hơn đúng `1` đơn vị so với giá trị hacker yêu cầu: `387170294533119999999` ![image](https://hackmd.io/_uploads/HyveL7W0kl.png) ⇒ điều này chứng minh rằng thanh khoản trong phạm vi tick `[110909, 111310]` (phạm vi đã được chính hacker cung cấp thanh khoản) là đủ để đáp ứng yêu cầu của hacker. Tiếp đến, nếu lượng token sử dụng (`usedAmount`) lớn hơn lượng người dùng chỉ định (`specifiedAmount`) trong phép hoán đổi exact input (**trường hợp được sử dụng trong vụ tấn công**), điều đó có nghĩa là tick sẽ không bị vượt qua, và `nextSqrtP` cần được tính dựa trên thanh khoản gia tăng ($ΔL$ - `deltaL`). Sau đó, giá trị $ΔL$ (`deltaL`) được tính toán dựa trên lượng đầu vào, thanh khoản hiện tại và mức giá hiện tại bằng cách sử dụng hàm `estimateIncrementalLiquidity`. Cuối cùng, mức giá sau hoán đổi `nextSqrtP` được xác định dựa trên `deltaL`, lượng đầu vào, giá hiện tại và thanh khoản thông qua hàm `calcFinalPrice`. Ngược lại, nếu lượng token cần thiết nhỏ hơn lượng người dùng chỉ định (tức là `nextSqrtP > 0`), `deltaL` sẽ được tính toán dựa trên `sqrtP` hiện tại và mục tiêu, và `nextSqrtP` sẽ là `sqrtP` tại tick tiếp theo. Phần chi tiết của nhánh này được lược bỏ vì không được sử dụng trong vụ tấn công. Những bước được nêu ở trên cho thấy rõ rằng: nếu tick không bị vượt qua, thì `nextSqrtP` do `computeSwapStep` trả về không được lớn hơn `sqrtP` của tick tiếp theo. Tuy nhiên, do sự phụ thuộc của mức giá vào thanh khoản (gồm thanh khoản gốc - base liquidity và thanh khoản gia tăng - delta liquidity), cùng với độ mất chính xác trong tính toán (precision loss), kẻ tấn công có thể thao túng `nextSqrtP` để nó lớn hơn, mặc dù tick không bị vượt qua. **Một số điểm cần chú ý như sau:** $$ \text{usedAmount} = \text{specifiedAmount} + 1 $$ kẻ tấn công đã kiểm soát quá trình swap sao cho không chạm đến tick kế tiếp (tick `111310`), dẫn đến kết quả: $$ \text{nextSqrtP} = 0 $$ Trong tình huống này, vì tick không bị vượt qua (tick is not crossed), nên `nextSqrtP` (tức là giá cuối cùng) sẽ được tính toán dựa trên delta thanh khoản (được tích lũy từ phí swap). Cụ thể, `delta` thanh khoản tăng thêm (`deltaL`) từ phí swap sẽ được tính bằng công thức: $$ \Delta L = \text{estimateIncrementalLiquidity}(|\text{absDelta}|, \text{currentSqrtP}) $$ Sau đó, giá cuối cùng `nextSqrtP` được tính như sau: $$ \text{nextSqrtP} = \text{calcFinalPrice}(|\text{absDelta}|, \text{liquidity}, \Delta L, \text{currentSqrtP}) $$ Hàm `calcFinalPrice` dùng để tính toán giá có cấu trúc như sau: ```solidity /// @dev calculates the sqrt price of the final swap step /// where the next (temporary) tick will not be crossed function calcFinalPrice( uint256 absDelta, uint256 liquidity, uint256 deltaL, uint160 currentSqrtP, bool isExactInput, bool isToken0 ) internal pure returns (uint256) { if (isToken0) { // if isExactInput: swap 0 -> 1, sqrtP decreases, we round up // else swap: 1 -> 0, sqrtP increases, we round down uint256 tmp = FullMath.mulDivFloor(absDelta, currentSqrtP, C.TWO_POW_96); if (isExactInput) { return FullMath.mulDivCeiling(liquidity + deltaL, currentSqrtP, liquidity + tmp); } else { return FullMath.mulDivFloor(liquidity + deltaL, currentSqrtP, liquidity - tmp); } } else { // if isExactInput: swap 1 -> 0, sqrtP increases, we round down // else swap: 0 -> 1, sqrtP decreases, we round up uint256 tmp = FullMath.mulDivFloor(absDelta, C.TWO_POW_96, currentSqrtP); if (isExactInput) { return FullMath.mulDivFloor(liquidity + tmp, currentSqrtP, liquidity + deltaL); } else { return FullMath.mulDivCeiling(liquidity - tmp, currentSqrtP, liquidity + deltaL); } } } ``` `absDelta` là số lượng swap được yêu cầu. Thêm vào đó, `deltaL` là lượng thanh khoản tự động được tái đầu tư vào pool từ phí phát sinh từ swap mà bạn đang thực hiện, và giá trị này rất nhỏ trong trường hợp này. Dựa trên điều này, giá trị được trả về bởi `calcFinalPrice` có thể được biểu diễn dưới dạng công thức. $$ \text{finalPrice} = \frac{\text{liquidity} + \frac{\text{absDelta} \times 2^{96}}{\text{currentPrice}}}{\text{liquidity} + \text{deltaL}} \times \text{currentPrice} $$ Kẻ tấn công muốn tăng giá trị này lên càng nhiều càng tốt để làm cho giá di chuyển ra khỏi tick, vì vậy họ đã đặt `absDelta` cao nhất có thể (kém một đơn vị so với số lượng mà nó sẽ lý thuyết đạt được trong một tick), kết hợp với việc`deltaL` đã bị **làm tròn xuống**( hàm `mulDivFloor`) một cách sai lầm, dẫn đến việc `nextSqrtP` bị **làm tròn lên**(hàm `mulDivCeil`) một cách không chính xác. Giá sau khi hoán đổi (`nextSqrtP`) được tính là: `20693058119558072255665971001964` Cao hơn giá tại tick `111310`: `20693058119558072255662180724088` Vì vậy, giá trị trả về thực tế sẽ cao hơn giá tại tick `111310` , đồng thời câu lệnh `while` sẽ kết thúc khi câu lệnh `break` trong cú pháp dưới đây được gọi. ```solidity // 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; } ``` Cuối cùng, giao dịch đã đưa giá ra khỏi tick và vào một phạm vi tick mới, nhưng vì `_updateLiquidityAndCrossTick` chưa được gọi → pool không thay đổi thanh khoản. Vì chúng ta đã ra khỏi phạm vi thanh khoản ở đây, thanh khoản cho phạm vi tick đó **lẽ ra phải được nhận diện là bằng không**. Tuy nhiên, vì `_updateLiquidityAndCrossTick` không được gọi, thanh khoản cho phạm vi tick đó lại được nhận diện là bằng với thanh khoản của phạm vi trước. ## Tính đúp thanh khoản (double the liquidity counting) **Swap 2** là một giao dịch swap rất bình thường. Nếu bạn thực hiện giao dịch swap theo hướng ngược lại, giá sẽ vượt qua tick `111310` , và hàm `_updateLiquidityAndCrossTick` sẽ được gọi để tiếp tục giao dịch. Lúc này, pool sẽ kiểm tra lại thanh khoản và tiến hành giao dịch như thể thanh khoản đã được gấp đôi trong khoảng thời gian đó. Ở đây, hacker đã cung cấp số lượng thanh khoản giống như trong pool trước khi cuộc tấn công xảy ra, có nghĩa là anh ta đã lừa pool di chuyển thanh khoản vốn có trước cuộc tấn công tới gần tick mà anh ta chỉ định và rút hết thanh khoản để trục lợi. Các **event logs** phát sinh từ hai lần swap **(swap 1 và 2)** xác nhận rằng mọi thứ đã diễn ra đúng như phân tích. Trước khi thực hiện swap 1, lượng thanh khoản - liquidity cho phạm vi tick đó là **74692747583654757908**. Nếu swap 1 được thực hiện một cách bình thường và hàm `_updateLiquidityAndCrossTick` được gọi khi giá vượt qua phạm vi tick, thì lượng thanh khoản ghi nhận trong **swap event** lẽ ra phải bằng **0**. Tuy nhiên, nếu chúng ta xem các sự kiện thực tế được tạo ra bởi swap 1, thì thấy rằng thanh khoản vẫn là **74692747583654757908**. ![image](https://hackmd.io/_uploads/HkRjnM-R1g.png) Tương ứng với điều đó, log sự kiện từ swap 2 tính toán lượng thanh khoản – liquidity là **149385495167309515816**, tức là gấp đôi con số trước đó một cách chính xác. ![image](https://hackmd.io/_uploads/SJGlaf-AJe.png) # Conclusion Vụ tấn công KyberSwap lần này có mức độ tinh vi rất cao. Hacker đã: * Xác định được một lỗ hổng trong quá trình tính toán hoán đổi (swap calculation) trong cơ chế thanh khoản tập trung – concentrated liquidity, và * Tính toán chính xác toàn bộ các tham số hoán đổi và cung cấp thanh khoản (liquidity supply inputs) có thể sử dụng để khai thác giao thức. Do đặc thù của giao thức thanh khoản tập trung – concentrated liquidity protocol, quy trình tính toán rất phức tạp với vô số phương trình, khiến cho các hacker khác và cả các auditors cũng khó phát hiện được lỗ hổng. ### References * https://blocksec.com/blog/yet-another-tragedy-of-precision-loss-an-in-depth-analysis-of-the-kyber-swap-incident-1#0x2-vulnerability-analysis * https://blog.kyberswap.com/post-mortem-kyberswap-elastic-exploit/ * https://medium.com/@organmo/kyberswap-hack-analysis-11-22-c421274e0f4b * https://medium.com/@zan.top/kyberswap-attack-analysis-unveiling-the-most-sophisticated-cyber-heist-in-history-5332d2a644b9 * https://x.com/0xdoug/status/1727613541115429314