
2023.11.23 Kyberswap exploit analysis
서론 Kyberswap은 Uniswap v3 에 수수료를 자동 재투자해주는 편의를 더해 주목받았다. 그런데 2023년 11월 23일 총 $48M 의 손실을 입는 공격을 당했다. 이 글은 Uniswap v3를 모방한 다른 프로토콜에선 동일한 문제가 없을지 취약점의 원인을 분석한다. 더불어 이의 핵심인 계산 과정의 올림, 내림 문제에 대한 결정 기준을 제시한다. Uniswap v3 의 기본 개념이 더 궁금하다면 Uniswap V3 Development Book 을참고하자. Rekt - KyberSwap - REKT Kyberswap 의특징 Kyberswap은 Uniswap v3의 Concentrated Liquidity AMM에 수수료를 자동으로 재투자하는 편의를 제공했다. 이를 reinvestment curve 라 부른다. 그 결과, 지난 거래들에서 쌓인 수수료는 reinvestL 에 저장되어 교환식의 L에 더해진다. 이와 별개로 교환식에 deltaL, 즉 이번 swap의 수수료에 의해 풀에 추가되는 유동성도 있다. 아래에서 fee는 수수료율을말한다. https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/reinvestment-curve 과정 같은 방법으로 여러 풀의 자금을 가져갔으므로 대표적인 frxETH-WETH 풀 공격 사례만분석해보자. 두 가지를 짚고가자. frxETH-WETH 풀의 token0는 frxETH, token1은 WETH 이다. token0의 가격을 기준으로 틱을 저장하므로 WETH를 팔 때 틱이증가한다. Pool.swap 함수에서 swapQty 가 양수이면 풀에 해당 토큰이 늘어난다. 즉 해당 토큰의 판매를 의미한다. isToken0 = false, swapQty > 0 이면 token1을 판매한다는 의미이므로 WETH 판매트랜잭션이다. 공격 과정은 아래와같다. swap 2, 3다이어그램 Aave에서 2000 WETH 를 flashloan 한다. swap1: 6.37 WETH 를 6.85 frxETH로 교환한다. Pool.swap(2000 WETH ) 을 호출해서 유동성이 공급되지 않은 가격 범위에 도달하려는 목적이다. 다만, frxETH의 가격이 1WETH보다 높은 구간에는 유동성이 적어 인자인 2000WETH와 달리 소량만 교환되었다. 이 때 currentTick은 110909이다. mint(token0 = frxETH, token1 = WETH, fee = 10, tickLower = 110909, tickUppwer = 111310, amount0Desired= 6948087773336076, amoun1Desired = 107809615846697233,) 을 호출해서 현재 틱을 최저 가격으로 아무도 유동성을 공급하지 않은 구간에 자신만 LP를민팅한다. removeLiquidity(tokenId=359, liquidity=14938549516730950591) 를 호출해서 일부 LP를 소각한다. 원인 분석 파트에서 설명하겠지만 공격을 가능하게 하는 liquidity 값들이 있고 이를 맞추기위함이다. swap2: swap(swapQty = 387170294533119999999, isToken0 = false) 을 호출해 WETH를 판매한다. 공격자는 387.170 WETH 를 내고 0.0579 frxETH를 얻는다. 이 때 currentTick이 공격자의 LP 범위의 끝인 111310 으로 업데이트된다. swap3: swap(swapQty = -396244493223555299358, isToken0 = false) 을 통해 WETH를 구매한다. 공격자는 0.0587 frxETH 를 내고 396.244 WETH를 얻는다. 이 때 currentTick = 111105 으로 업데이트되어 공격자의 LP 범위의 (110909, 111310] 의 사이로들어온다. flashloan 으로 빌렸던 2000 WETH와 0.05%의 프리미엄 1WETH 를갚는다. https://explorer.phalcon.xyz/tx/eth/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3 이 과정에서 풀의 frxETH, ETH 수량의 변화를보자. 풀의 frxETH 수량 변화: 0.0000788819 frxETH 증가. 아래 계산식은 decimal 18기준이다. (-swap2에서의 감소량 + swap3에서의 증가량) = -5789927137555359 + 5868809110205016 = 78881972649657 2. 풀의 WETH 수량 변화: 9.074 WETH감소. (swap2에서의 증가량 — swap3에서의 감소량) = 387170294533119999999 -396244493223555299358 = -9.074e18 공격자는 WETH를 판매, 구매하는 zero-sum 트랜잭션에서 9.074 WETH를벌었다. 원인 분석 공격자가 공급한 LP 토큰의 양을 L이라고 하자. 원래 swap2에서 frxETH 가격이 공격자가 공급한 LP 범위의 오른쪽 끝인 tick 111310 을 넘으면 Pool._updateLiquidityAndCrossTick 함수를 호출해야 한다. 이는 유동성이 더 이상 없는 가격 구간으로 넘어가기 때문에 poolData.baseL을 L 에서 0으로 감소시키기 위함이다. 문제는 이 함수가 실행되지 않아서 poolData.baseL = L 인 상태로 남아있었다는점이다. 게다가 swap3에서 frxETH를 팔면서 frxETH 가격이 떨어질 때 tick 111310 이하로 내려오며 공격자가 공급했던 유동성 L이 다시 더해졌다. frxETH가 아주 비싼 구간에 총 2L의 유동성이 공급된 상태로 잘못 계산된 것이다. 오더북에 비유하면 사용자들이 1frxETH 당 1WETH에 사겠다고 올린 frxETH 매수 주문을 frxETH가 아주 비싼 구간, 1frxETH 당 1.0001 ** 111310 = 68216.6 WETH에 사겠다는 것으로 바꿨고 공격자는 frxETH를 아주 비싸게팔았다. 이제 swap2에서 baseL 이 업데이트되지 않은 이유를알아보자. Pool.swap 함수 Uniswap v3 방식의 풀은 여러 범위에 공급된 유동성이 겹쳐 있을 때 while loop를 돌며 평평한 구간에서 거래를 해 나간다. 예를 들어 아래 그림에서 [p0, p1), [p1, p2), [p2, p3) 의 순서로 가격이 올라가며 거래된다. 이후 설명에서 “이번 가격 구간” 은 while loop에서 이번 iteration에서 거래가 이뤄지는 가격 구간을지칭한다. Pool.swap 함수 중 https://github.com/KyberNetwork/ks-elastic-sc/blob/main/contracts/Pool.sol#L412-L418 부분의 “if price has not reached the next sqrt price” 라는 주석을 보자. if (swapData.sqrtP!= swapData.nextSqrtP)를 swapData.sqrtP < swapData.nextSqrtP 으로 가정하고 있다. 그 이유는 SwapMath.computeSwapStep함수에서 swapData.sqrtP <= swapData.nextSqrtP가 성립하도록 구현했기때문이다. 그런데 이번 공격에서는 nextSqrtP < sqrtP 라서!= 가 성립하고 break했기 때문에 swap 후 가격이 유동성을 공급한 tick을 지나갔음에도 baseL 이 감소되지 않았다. 그렇다면SwapMath.computeSwapStep함수가 swapData.sqrtP <= swapData.nextSqrtP 를 보장하지 못한 이유를 찾아야한다. https://github.com/KyberNetwork/ks-elastic-sc/blob/main/contracts/Pool.sol#L412-L419 SwapMath.computeSwapStep 함수 calcReachAmount 에서 이번 가격 구간 [currentSqrtP, targetSqrtP) 에서 swap 가능한 최대 WETH를 계산한 뒤 usedAmount에 저장한다. WETH를 파는 swap2는 유저가 보내는 수량을 swapQty에 명시했으므로 isExactInput 이다. 또한 usedAmount > specifiedAmount에 해당되는데 이번 가격구간의 최대, 즉 targetSqrtP만큼 상승하기에는 유저가 판매하는 수량 specifiedAmount가 모자란다는 의미다. 따라서 이번 WETH 거래량 usedAmount을 specifiedAmount로 수정한다. 이제 WETH 수량이 정해졌으므로 아래의 calcFinalPrice계산식(2)를 이용해 nextSqrtP를 계산한다. 문제는 여기서 nextSqrtP가 targetSqrtP 보다 크다는것이다. 이제 calcReachAmount 의 결과와 calcFinalPrice 의 결과가 모순되는 이유를찾아보자. https://github.com/KyberNetwork/ks-elastic-sc/blob/main/contracts/libraries/SwapMath.sol#L50-L79 SwapMath.calcReachAmount 계산 아래 식 (3)을 계산해서 이번 가격 구간에서 최대로 판매 가능한 WETH 수량을 반환한다. p_1은 현재 가격, p_2는 거래 후 가격을말한다. https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/pool-process-flows#swap-exact-input-from-token1---token0 풀어씀 수수료 재투자로 인해 xy = L² 가 변형된 식이다. 원래 Uniswap v3의 계산식은 uniwapv3book 에 잘 설명되어있다. y_1은 저장을 안 하기 때문에 L_1 * \sqrt P_1 로계산한다. SwapMath.calcReachAmount 코드 이 함수에 잘못된 부분은 없다. 다만 올림/내림의 선택 기준을 알려준다. swap2에 해당하는 isExactInput: true, isToken0: false 부분을보자. calcReachAmount 계산식 https://github.com/KyberNetwork/ks-elastic-sc/blob/main/contracts/libraries/SwapMath.sol#L131-L140 Q. numerator와 reachAmount를 mulDivFloor로 내림하는이유는? A. reachAmount, 즉 최대로 판매 가능한 WETH 수량은 내림하는 것이 안전하다. amount 계산에서는 이번 가격 구간의 최대 가격 미만에서 팔 수 있다고 판단했는데 뒤의 nextSqrtP 계산에서는 이번 가격 구간의 최대값을 초과해서 업데이트하지 않아야 틱을 넘으면서도 유동성 업데이트를 하지 않는 일을 방지할 수 있다. 같은 이유로 (isExactInput, isToken0) 에 따른 4가지 경우 모두 numerator, reachAmount 를내림한다. SwapMath.estimateIncrementalLiquidity 함수 deltaL을 계산하는 코드다. Kyberswap에서 이번 swap의 수수료도 재투자해서 추가되는 유동성을 말한다. SwapMath.calcReachAmount 계산에서 식(1)에 해당한다. swap2에서 파는 WETH가 token1 이기 때문에 \Delta y로계산한다. estimateIncrementalLiquidity 계산식 https://github.com/KyberNetwork/ks-elastic-sc/blob/main/contracts/libraries/SwapMath.sol#L186-L193 여기가 문제다. 주석의 “round up deltaL” 과 반대로 deltaL을 내림하고 있다. deltaL이 감소하여 아래 calcFinalPrice 식의 분모가 작아져서 calcFinalPrice 의 반환값이증가한다. SwapMath.calcFinalPrice 함수 5번 swap의 isExactInput: true, isToken0: false 부분은 Uniswap v3의 SqrtPriceMath.getNextSqrtPriceFromAmount1RoundingDown에 수수료 재투자가 추가된식이다. Uniswap SqrtPriceMath 식 calcFinalPrice 계산식 Uniswap SqrtPriceMath 식의증명 SwapMath.calcReachAmount 계산에서 식(2) 에해당한다. https://github.com/KyberNetwork/ks-elastic-sc/blob/main/contracts/libraries/SwapMath.sol#L273-L275 가격이 상승하는 과정이므로 다음 가격은 최대한 내림으로 계산해야 예상치 못한 crossTick을 막는다. 그런데 estimateIncrementalLiquidity함수의 버그로 인해 분모에 포함된 deltaL이 내림되고 calcFinalPrice의 반환값인 \sqrt p_2 는 증가한다. 유동성은 작고 가격은 높은 구간이므로 분모의 deltaL을 1 감소시키는 것이 nextSqrtP 의 값을 2e11 wei이상 증가시키는 결과를 낳았다. 아래는 테스트 코드에서 deltaL을 내림하도록 수정하면 \sqrt p_2 가 유동성 공급 범위의 최대 틱인 111310보다 작음을 확인한표이다. estimateIncrementalLiquidity에서 floor, ceil 에 따른 결과비교 정리 swap2, 3다이어그램 WETH를 구매하는 swap3 과정을 천천히 보자. 여기서는 isExactInput = true 로 usedAmount를 WETH 수량, returnedAmount를 frxETH 수량으로 보면된다. 첫 번째 computeSwapStep은 (tick 111310, 현재가] 이다. 이 때는 거래하는 가격 범위가 매우 좁으므로 반환값이 usedAmount -2, returnedAmount=1 이다. decimal 18 기준으로 0에 가까운 미미한 WETH를구매한다. 이제 다음 가격 구간으로 넘어가기 위해 _updateLiquidityAndCrossTick에서 newNextTick = 110909 로 업데이트 한다. 이 때 공격자의 유동성 L이중첩된다. 다음 가격 구간 (tick 110909, tick 111310] 으로 넘어간 뒤 computeSwapStep의 반환값은 usedAmount = -396244493223555299354, returnedAmount = 5868809110205015 으로 396.24 WETH 가구매된다. swap3 invocation flow: 첫 iteration이 currentTick=111,310 에서시작한다. swap3 invocation flow (오른쪽 잘린 부분): 다음 iteration에서 110909 틱으로내려간다. 해결책 Brute force로 nextSqrtP 가 넘어가는 인자를 찾아주는 POC를 이용한다. 아래처럼 SwapMath.estimateIncrementalLiquidity 에서 mulDivFloor를 mulDivCeiling 으로 변경하고 테스트를 실행한다. 그러면 Attack POC 테스트가 동작하지않는다. https://github.com/qpzm/kyber-exploit-example/blob/main/contracts/libraries/SwapMath.sol#L134-L136 $ forge test --mt testBruteForceExploit Running 1 test for test/Kyber.t.sol:KyberAttack [FAIL. Reason: revert: unable to find a valid liquidity number] testBruteForceExploit() (gas: 28770705537) Test result: FAILED. 0 passed 1 failed 0 skipped finished in 120.28s 참조 https://etherscan.io/tx/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3#eventlog 104, 155,164 https://explorer.phalcon.xyz/tx/eth/0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3 Yet Another Tragedy of Precision Loss: An In-Depth Analysis of the KyberSwap Incident | Phalcon Blog Reinvestment Curve https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/pool-process-flows#swap-exact-input-from-token1---token0 KyberSwap Hackㄷ(11/22) https://twitter.com/0xdoug/status/1727613541115429314 2023.11.23 Kyberswap exploit analysis was originally published in Elysia TechBlog on Medium, where people are continuing the conversation by highlighting and responding to this story.
