2025年2月12日,StarkNet上的借贷协议zkLend[1]因操纵其累加器机制,遭受了约1000万美元的攻击。攻击者利用闪电贷和四舍五入漏洞,人为地夸大了抵押品价值,并从协议中借出其他资产以获利。
然而,从安全角度来看,仍然缺乏详细准确的技术分析。尽管其他安全研究人员的分析提供了有价值的见解,但仍存在一些误解,尤其是在攻击分析方面。zkLend随后发布的官方事后报告[2]提供了简化的描述,但缺乏详细的技术分析。在本博客中,我们旨在提供全面的审查以阐明此事件。
主要收获 (TL;DR)
-
此事件的根本原因源于以下三个问题的结合:
- 空市场初始化允许任意资产存入。
- zkLend闪电贷中的特定捐赠机制允许操纵累加器,一个用作缩放因子的全局变量,动态调整用户的抵押品余额。
- 精度损失是由于截断引起的。与经典的除法精度损失不同,分母从1开始,但被夸大到非常大的值,导致在燃烧份额代币时低估。
-
攻击者并非从其他用户的wstETH存款中获利。相反,攻击者利用漏洞操纵了抵押品余额,使用少量wstETH作为初始资金,将抵押品余额增加到超过7000枚wstETH,从而能够从市场借入其他资产。
在接下来的章节中,我们将首先提供zkLend的一些关键背景信息。随后,我们将深入分析这些问题以及相关的攻击。
0x1 背景:理解zkLend核心协议
zkLend是StarkNet上的一个借贷项目,支持常见的借贷协议,如抵押贷款和闪电贷。让我们深入了解这两种协议的实现细节。
0x1.1 抵押贷款
抵押贷款是指用户将特定资产存入协议作为抵押品,以换取借入其他资产的过程。抵押品的价值用于确定借款能力。需要注意的是,借贷协议通常不直接存储抵押品的资产价值;相反,它们使用以下公式计算:
collateral_balance = lending_accumulator * raw_balance
具体来说,lending_accumulator是一个缩放因子,动态调整每个用户的抵押品价值,而raw_balance代表用户在该市场中持有的实际份额。raw_balance是使用lending_accumulator从collateral_balance派生的。
这种设计的目的是什么? 它使协议能够有效地管理抵押品价值,同时激励用户存入资产。通过将协议收益的一部分分配给抵押品提供者,lending_accumulator会增加,从而同等地放大所有用户的抵押品价值。
0x1.2 zkLend上的闪电贷
闪电贷是一种无抵押贷款,用户可以在很短的时间内(通常在一个交易内)从协议中借入资产。如果借款人未能偿还贷款或满足规定条件,整个交易将被回滚,贷款也不会执行。
在zkLend的闪电贷实现中,有一个独特的捐赠机制。具体来说,当用户偿还资产时,他们不仅会归还所需的最低金额,还可以额外捐赠资金。协议会跟踪这些捐赠的资金并相应地更新lending_accumulator。此过程在thesettle_extra_reserve_balance()函数中实现。更新lending_accumulator的公式如下:
new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply
reserve_balance:合同持有的标的代币(例如wstETH)的总量,包括用户捐赠的代币数量。totaldebt:所有借款用户的总债务。amount_to_treasury:协议的收入。ztoken_supply:份额代币(例如zwstETH)的总供应量。当用户存入wstETH时,zkLend代币合同会铸造等量的zwstETH。
在理解了zkLend的核心协议后,我们将正式解释攻击者如何通过操纵lending_accumulator和raw_balance变量来操纵其抵押品资产。
0x2 攻击分析
攻击者利用zkLend合同中的以下机制和漏洞来操纵抵押品价值:
- 操纵
lending_accumulator- 空市场:在攻击之前,zkLend的wstETH代币市场是空的,为操纵提供了完美的条件。此外,zkLend市场合同允许任何人向空市场存入任意数量的资产。攻击者存入少量资产,极大地夸大了
lending_accumulator的值。 - 捐赠机制:zkLend市场合同的
flash_loan()函数具有独特的捐赠机制。具体来说,当用户偿还闪电贷时,市场合同会计算退回的超额资金,并增加全局lending_accumulator变量,从而放大合同中所有用户的抵押品价值。
- 空市场:在攻击之前,zkLend的wstETH代币市场是空的,为操纵提供了完美的条件。此外,zkLend市场合同允许任何人向空市场存入任意数量的资产。攻击者存入少量资产,极大地夸大了
- 操纵
raw_balance- 四舍五入行为:份额代币燃烧过程中的除法运算使用截断,导致用户在提款时
raw_balance的变化被低估。
- 四舍五入行为:份额代币燃烧过程中的除法运算使用截断,导致用户在提款时
通过操纵这两个变量,攻击者能够将抵押品余额增加到7000枚wstETH以上,并从市场借入其他资产以获利。
0x2.1 操纵lending_accumulator变量
0x2.1.1 空市场初始化
通过检查攻击前市场合同的交易记录,我们可以看到攻击者最初向wstETH市场合同存入了1 wei的wstETH。通过查看此交易的内部调用,可以清楚地看出wstETH市场合同持有0 wstETH,zwstETH的总供应量也为0。
因此,我们可以确认zkLend wstETH市场没有先前的存款或借款。reserve_balance和ztoken_supply都处于初始值0,lending_accumulator的初始值为1。这种空市场场景为后续攻击创造了条件,使攻击者能够用最少量的wstETH大幅放大lending_accumulator。
0x2.1.2 通过闪电贷操纵lending_accumulator
接下来,在此交易中,攻击者调用flash_loan()函数,借入1 wei wstETH并偿还1000 wei wstETH。多余的999 wei被视为捐赠并记录到合同的reserve_balance中。
根据计算lending_accumulator的公式,此交易导致lending_accumulator从1增加到851.0。
0x2.1.3 重复执行flash_loan()
攻击者总共执行了10次flash_loan()调用,每次只借入1 wei wstETH,但偿还了更大的金额。结果,lending_accumulator升级到一个天文数字值4,069,297,906,051,644,020(4.069 × 10^18),这恰好与wstETH的十进制精度相符。
0x2.2 操纵raw_balance变量
在将lending_accumulator操纵到约4.069 × 10^18之后,攻击者使用4.069297906051644020 wstETH调用了市场合同的deposit()函数。根据lending_accumulator的最新值,攻击合同的raw_balance变为2。
0x2.2.1 操纵raw_balance的第一次交易
在此交易中,攻击者调用了攻击合同的callflashloandraaan()函数。尽管此合同不是开源的,但根据内部调用跟踪,可以推测此函数包含一个循环,执行以下操作:
- 存入:攻击者将一定数量的wstETH存入市场合同。
- 提取:攻击者提取特定数量的wstETH。
代币转账记录分析
可以看出,攻击者存入的wstETH数量总是lending_accumulator的整数倍,例如,是lending_accumulator值的2倍(例如,8.13859)。
然而,提取的wstETH数量是lending_accumulator值的1.5倍(例如,6.10394)。
通过计算,我们可以确定提取的wstETH数量超过了存入的数量。为什么会发生这种情况?
四舍五入行为
通过审查deposit()和withdraw()方法的实现,我们可以看到这两个方法涉及zwstETH的铸造和燃烧。具体操作如下:
市场合同中的`mint()`函数
市场合同中的`burn()`函数
mint()和burn()过程都包含缩减逻辑。缩减逻辑涉及带有向下取整(向下取整到最接近的整数)的整数除法,这在利用中起着关键作用。
当攻击者燃烧一定数量的zwstETH时,应用了缩减逻辑。由于lending_accumulator的操纵值异常高(约4,069,297,906,051,644,020),这次除法导致攻击者的raw_balance仅减少1个单位,尽管燃烧了超过6个zwstETH。
攻击者的raw_balance变化总结在下表中:
我们可以看到,在此交易中,攻击者反复执行存入-提取逻辑,利用withdraw()函数中的精度损失,导致raw_balance差值被低估。最终,用户的raw_balance从2增加到3,额外获得了一个单位。
0x2.2.2 后续攻击过程
随后的攻击交易遵循了与第一次攻击相同的模式:攻击者反复进行存入-提取交易以获取wstETH。
获取的wstETH被重新存入市场,进一步增加了raw_balance,导致攻击者的抵押品价值不断上升。
示例解释
我们使用以下交易作为说明。
- 共进行了30次存款,每次存入4.069 wstETH。
- 共进行了30次提款,每次提取6.104 wstETH。
- 根据计算,在此周期之后,攻击者成功提取了61.39 wstETH。
此外,值得注意的是,在这些攻击交易之间,调用了几个increase()方法。这些方法用于将一定数量的wstETH从攻击者账户转移到攻击合同,然后攻击合同为后续向市场合同的存款提供资金。
这些操作提升了raw_balance的价值,使攻击者能够继续增加抵押品价值。最终,攻击者的raw_balance达到了1,724,价值7,015.4 wstETH,这足以从市场借入其他资产。
0x3 利润分析
0x3.1 借入其他类型资金
在操纵抵押品价值后,攻击者借入了其他类型的资金,并继续进行了以下交易(节选):
0x3.2 将借入资金桥接到Layer1
通过检查攻击者合同的桥接交易,可以看到攻击者将部分借入资金桥接到了Layer 1。
0x4 结论
总之,这次对zkLend协议的攻击凸显了去中心化借贷协议的设计和安全方面的一些重要启示:
- 市场初始化和资产存入条件:
最初的空市场允许攻击者存入少量wstETH并操纵
lending_accumulator,从而获得了利用的杠杆。确保充足的流动性基础或限制早期市场阶段的资产捐赠可以帮助防止类似的攻击。 - 正确累加器机制的重要性:
攻击者利用
flash_loan()函数中的捐赠机制来操纵lending_accumulator,夸大了所有用户的抵押品价值。具有累加器机制的协议应防范缩放因子被轻易操纵。 - 四舍五入行为和精度损失:
zwstETH代币燃烧过程中的四舍五入问题导致精度损失和
raw_balance的低估,使攻击者能够操纵raw_balance。协议应使用更高的精度或验证检查来防止此类利用。
再次强调,此事件凸显了及时通知初始化和运营状态以及主动威胁预防以减轻潜在损失的重要性。
参考
[1] https://zklend.com/
[2] zkLend安全事件事后报告:https://drive.google.com/file/d/10i1dh_J89tPPw7KRcmFIVM6iNrJZAyfi/view



