2023年11月23日,我们观察到一系列针对KyberSwap的攻击。这些攻击导致总计损失超过4800万美元。我们的初步分析表明,该漏洞是由于Tick操纵和双重流动性计数引起的。然而,由于篇幅限制,我们无法在该帖子中深入探讨其中的详细信息。尽管此后其他安全研究人员进行了有见地的分析,但该问题的根本原因——精度损失——仍然未被揭露。
引人注目的是,几天后事态变得更加复杂。2023年11月30日,在与官方进行多轮讨论后,攻击者发送了一条消息,这条消息在外人看来似乎充满挑衅,要求完全控制权。姑且不论这个,攻击者还透露了一个关键信息:问题确实与精度损失有关,如下图所示。这一披露加强了我们调查的证据。因此,我们的目标是在本报告中呈现一个全面的分析。

主要发现 (TL;DR)
-
我们的调查显示,根本问题源于KyberSwap再投资过程中不正确的舍入方向。这随后导致Tick计算不当,最终导致双重流动性计数。
-
此事件突显了DeFi协议中精度损失问题的复杂性和隐蔽性,对整个社区构成了严峻挑战。
-
这些攻击的频率再次提醒我们主动威胁预防措施的关键必要性,这可以极大地帮助减少未来的损失。
在接下来的章节中,我们将首先提供关于KyberSwap的一些重要背景信息。随后,我们将深入分析该漏洞和相关的攻击。
0x1 背景
KyberSwap[1] 是一种去中心化自动化做市商 (CLAMM) 平台。 为满足集中流动性的市场需求,KyberSwap Elastic[3] 基于Uniswap V3[2]推出,并进行了一些改进,包括再投资曲线,以实现流动性提供收益的自动复利。
0x1.1 Tick 和平方根价格
Uniswap V3类CLAMM中的Tick用于离散地标记价格,以便LP可以在固定范围内而非整个范围内提供流动性(因此得名“集中式”)。[4]
为了让LP能够指定具有自定义价格区间的流动性头寸,协议需要一种方法来跟踪跨越不同价格点的聚合流动性。Uniswap V3通过将可能价格的空间划分为离散的“ticks”来实现这一点,LP可以在任意两个ticks之间贡献流动性。
根据[5],流动性可以放置在任意两个ticks之间的范围内(不一定是相邻的),即一对tick索引(较低tick和较高tick)。具体来说,每个tick的价格(在整数索引i处)定义如下:
实际上,使用的是平方根价格(表示为sqrtP或sqrtPrice):
根据当前的平方根价格,也可以计算当前的tick:
将平方根价格与流动性L一起使用是一种避免同步变化的实用方法。具体来说,当在tick内进行交换时,价格会发生变化;当跨越tick,或进行流动性铸造或销毁时,流动性会发生变化。有关更详细的解释,请参阅Uniswap V3白皮书[5]。
显然,给定tick只计算一个平方根价格,但多个平方根价格可能指向同一个tick。
0x1.2 再投资曲线
Uniswap V3 기반 CLAMM存在LP费用池利用率低以及再投资所需高昂Gas费用的问题。因此,KyberSwap采用了再投资曲线[6]来解决这一问题:
再投资曲线的设计宗旨是原生再投资集中流动性模型中原本未被利用的LP费用。这意味着集中流动性头寸的LP费用能够自动复利,无需Gas费用或手动管理开销。此外,LP仍然可以选择在任何时候单独提取其自动复利的费用收益。
再投资曲线的关键在于,每次交换中收集的费用在交换过程中会累积到池中作为“再投资流动性”,其范围是无限的。再投资代币被铸造给LP,累积的再投资流动性也相应地分配给LP。此外,再投资流动性也参与交换和价格计算过程。
精确地说,不是使用恒定乘积公式:
每次交换中,费用会累积为ΔL:
在(假设价格偏差低于某个阈值的)条件下,ΔL的计算可以简化为:
然后,交换金额和最终价格可以从修改后的恒定乘积公式得出:
上述计算对应的代码显示在以下相关池代码片段的computeSwapStep函数中。

需要注意的是,由于再投资流动性,此函数中的liquidity是两个组成部分的总和:baseL代表基础流动性,reinvestL代表累积的再投资流动性。
0x1.3 KyberSwap 中的 Swap
Uniswap V3中的Swap控制流可以表示如下[5]:

因此,前面讨论的KyberSwap池的swap函数实现可以抽象为下图:

与Tick计算相关的关键逻辑位于Swap的while循环内,由蓝色矩形突出显示。具体来说,主要逻辑涉及computeSwapStep函数和_updateLiquidityAndCrossTick函数。前者计算给定Swap的输入和输出金额以及nextSqrtP等关键状态,后者处理跨越Tick的情况。
传统上,当价格上涨时,我们称之为Tick向右/向上移动;否则,我们称Tick向左/向下移动。
为了更好地理解后面将要讨论的漏洞,探索computeSwapStep函数的相关代码逻辑至关重要,如下所示:

首先,从第50到57行,调用calcReachAmount函数来计算达到targetSqrtP(下一个Tick或用户指定的Target Price)所需的输入代币数量。
接下来,在第59到62行,进行一项测试,以确定是否应该跨越Tick。
具体来说,如果使用的数量(usedAmount)大于精确输入Swap(攻击中使用的案例)中用户指定的数量(specifiedAmount),则意味着不应该跨越Tick,并且需要从增量流动性(deltaL,即delta流动性)推导出nextSqrtP。
- 随后,在第70到79行,使用
estimateIncrementalLiquidity函数根据输入金额、当前流动性和价格推导出ΔL(deltaL)。最后,使用calcFinalPrice函数根据deltaL、输入金额、当前价格和流动性计算Swap后的最终价格nextSqrtP。
相反,如果所需金额小于用户指定的金额(表示nextSqrtP>0),则使用当前和Target sqrtP计算deltaL,并且nextSqrtP为下一个Tick的sqrtP。由于该分支在攻击中未使用,因此省略了详细信息。
上述步骤清楚地表明,如果未跨越Tick,computeSwapStep返回的nextSqrtP不应大于下一个Tick的sqrtP。然而,由于价格依赖于流动性(基础流动性和Delta流动性)以及精度损失,攻击者能够操纵nextSqrtP使其更大,而Tick并未被跨越。
0x2 漏洞分析
根本原因在于SwapMath合约(由computeSwapStep函数调用)中Delta流动性计算(即estimateIncrementalLiquidity函数)内的舍入方向不正确,导致Tick计算存在缺陷。这反过来又会不当影响后续的Tick计算。

有趣的是,通过检查第188行(由蓝色矩形突出显示)的注释,我们发现deltaL意图是向上舍入,以便向下舍入nextSqrtP。然而,由于第189行使用了mulDivFloor函数,deltaL被错误地向下舍入了。因此,nextSqrtP被不准确地向上舍入了。
0x3 攻击分析
攻击者发起了多次攻击交易,每次交易耗尽多个池。为简化起见,以下讨论基于攻击交易中的第一次攻击。
核心攻击逻辑包含以下六个步骤:
-
通过AAVE的闪电贷借入2,000 WETH。
-
在受害者池0xfd7b中,用6.850 WETH兑换6.371 frxETH。此步骤用于将当前Tick和
currentSqrtP推送到当前没有流动性的位置。
currentSqrtP似乎是攻击者随机选择的,并且交换恰好在此价格停止。- 此步骤后基础流动性(
baseL)为零,但再投资流动性(reinvestL)非零。
- 向池中添加流动性,然后移除部分流动性。此步骤用于控制流动性到所需金额的范围。
- Tick范围根据
currentSqrtP选择。 - 所需的攻击流动性可以从Tick范围推导出来,尽管相应的计算逻辑需要进一步研究。
- 在池中用387.170 WETH兑换0.06 frxETH。此步骤用于操纵当前Tick,以便**
nextTick==currentTick**。
- 输入金额根据流动性和
currentSqrtP选择。
-
在池中用0.06 frxETH兑换396.244 WETH。请注意,兑换方向与上一步相反。在此步骤中,流动性被重复计算,以使交换有利可图,并最终耗尽池。
-
偿还闪电贷,并收获6.364 WETH和1.117 frxETH。
显然,最后两次交换(步骤4和步骤5)是操纵Tick计算并使交换有利可图以耗尽池子的关键攻击步骤。我们将在以下小节中详细探讨。
值得注意的是,步骤3对于操纵流动性至关重要。由于需要通过舍入操作进行精确的Tick操纵,直接添加流动性难以实现目标。流动性移除是为了精确控制攻击者所需的范围内流动性。
0x3.1 步骤4:操纵当前Tick和currentSqrtP
在之前的步骤(步骤1和2)之后,攻击者已准备好Tick范围和流动性以进行操纵。具体而言:
currentSqrtP位于所需位置- 当前Tick = 110,909,下一个Tick = 111,310,环绕
currentSqrtP
此步骤将WETH兑换为frxETH。在computeSwapStep函数中,我们有以下执行跟踪:

如上图所示,到达目标(即下一个Tick)的金额将通过调用calcReachAmount函数来计算:
usedAmount=calcReachAmount(liquidity,currentSqrtP,targetSqrtP)
请注意,此计算可以在交换之前完成。通过仔细选择specifiedAmount(usedAmount = specifiedAmount + 1),攻击者控制了交换,使得目标(即下一个Tick 111,310)未被达到,导致nextSqrtP = 0。
在这种情况下,由于Tick未被跨越,nextSqrtP(即最终价格)需要从Delta流动性(作为交换费用累积)中推导出来。
首先,通过以下方式计算来自费用的增量流动性deltaL:
deltaL=estimateIncrementalLiquidity(absDelta,currentSqrtP)
然后是最终价格nextSqrtP:
nextSqrtP=calcFinalPrice(absDelta,liquidity,deltaL,currentSqrtP)
回顾上一节讨论的舍入方向错误,这里deltaL被错误地向下舍入,导致nextSqrtP向上舍入。具体来说,在此情况下,基于相同的absDelta(387,170,294,533,119,999,999),由于舍入方向不同,计算结果也不同:

因此,在步骤4完成Tick操纵后,当前状态总结如下:
currentSqrtP为20,693,058,119,558,072,255,665,971,001,964,略大于Tick 111,310的sqrtP(Tick 111,310的sqrtP = 20,693,058,119,558,072,255,662,180,724,088)。- 当前Tick = 111,310,下一个Tick = 111,310

如上图所示,步骤4中的交换巧妙地欺骗了池子,使其认为Tick 111,310未被跨越。然而,实际上,currentSqrtP确实大于Tick 111,310的sqrtP。
0x3.2 步骤5:双重流动性计数
基于步骤4的操纵,步骤5中的攻击逻辑相对简单。此时,攻击者策划了一次从frxETH到WETH的反向交换,这将导致Tick和currentSqrtP向左移动。具体来说,computeSwapStep函数在循环内被调用了两次,这最终以一种意想不到的方式触发了双重流动性计数[7],并因此产生了额外的利润。

如上跟踪所示:
-
在
computeSwapStep函数第一次调用时,currentSqrtP被移到了Tick 111,310的sqrtP。这是一次微小的交换,仅使用了3 wei的frxETH来实际达到Tick 111,310。随后,在_updateLiquidityAndCrossTick函数中,当前Tick应该跨越Tick 111,310(向左/向下移动),尽管在步骤4中它并未真正地向右/向上跨越Tick 111,310。这导致Tick 111,310的流动性被计算了两次。 -
在
computeSwapStep函数第二次调用时,之前双重计算的流动性可能导致产生额外的利润。具体来说,利用这种双重流动性计数,最后一步的交换价格被扭曲,导致交换出更多的WETH,从而产生利润。
0x4 攻击和利润总结
截至发稿时,我们已在多个链(包括以太坊、Optimism、Polygon、Arbitrum、Avalanche和Base)上观察到多起在野攻击,造成了超过4800万美元的损失。这些攻击由不同的攻击者发起,如下所示:
我们准备了一份文档收集了所有这些攻击交易。请参阅该文档以获取更详细的信息。
0x5 结论
总之,这是一个源于不正确舍入逻辑的微妙漏洞。该漏洞的利用极其复杂。事实上,今年我们已经观察到了一系列与精度损失问题相关的安全事件,这对社区构成了重大挑战。
再次强调,这些持续的攻击表明了主动威胁预防策略的重要性,该策略可以有效帮助减轻潜在损失。
参考
[1] https://docs.kyberswap.com/
[2] https://blog.uniswap.org/uniswap-v3
[3] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic
[4] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/tick-range-mechanism
[5] https://uniswap.org/whitepaper-v3.pdf
[6] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/reinvestment-curve



