zkLend 漏洞利用事后总结:揭开千万美元闪贷攻击的细节并澄清误解

本博客将对 zkLend 事件进行详细分析,以澄清误解。

zkLend 漏洞利用事后总结:揭开千万美元闪贷攻击的细节并澄清误解

2025 年 2 月 12 日,StarkNet上的一个借贷协议 zkLend [1] 通过对其累加器机制的复杂操纵,被利用了约 1 千万美元。攻击者利用闪贷和四舍五入漏洞,人为抬高抵押品价值,从协议中借用其他资产获利。

然而,从安全角度来看,仍然缺乏详细准确的技术分析。尽管其他安全研究人员的现有分析提供了有价值的见解,但仍存在一些误解,尤其是在攻击分析方面。zkLend 后来发布的官方验尸报告[2]提供了简化的描述,但缺乏详细的技术分析。在本博客中,我们旨在提供全面的分析,以澄清此次事件。

关键启示(TL;DR)

  • 此次事件的根本原因源于以下三个问题的结合**:

    • 空市场初始化****允许任意存放资产。
    • zkLend 的闪贷****中的特定捐赠机制允许操纵累加器(一个全局变量,作为动态调整用户抵押品余额的缩放因子)。
    • 截断****会造成精度损失。与传统除法中的精度损失不同,分母从 1 开始,但被膨胀到一个非常大的值,导致在烧毁份额令牌时出现低估。
  • 攻击者没有从其他用户存入的 wstETH 中获利**。相反,攻击者利用漏洞操纵抵押品余额,使用少量 wstETH 作为初始资金,将抵押品余额增加到 7,000 多 wstETH,从而能够从市场上借入其他资产。

在接下来的章节中,我们将首先介绍有关 zkLend 的一些重要背景信息。随后,我们将对问题和相关攻击进行深入分析。

0x1 背景:了解 zkLend 的核心协议

zkLend 是 StarkNet 上的一个借贷项目,支持抵押贷款和闪贷等常见借贷协议。让我们深入了解这两个协议的实现细节。

0x1.1 抵押贷款

抵押贷款是指用户将特定资产作为抵押品存入协议以换取其他资产借款的过程。抵押物的价值用于确定借贷能力。值得注意的是,借贷协议通常不会直接存储抵押物的资产价值,而是使用公式计算:

抵押品余额 = 借贷积累器 * 原始余额

具体来说,"lending_accumulator "是一个缩放因子,用于动态调整每个用户的抵押品价值,而 "raw_balance "则代表用户在市场上持有的实际份额。原始余额 "由 "抵押品余额 "和 "借贷累积器 "得出。

这种设计的目的是什么? 它使协议能够有效地管理抵押品价值,同时激励用户存入资产。通过将协议收益的一部分分配给抵押品提供者,"借贷累积器 "会增加,从而同时按比例放大所有用户抵押品的价值。

0x1.2 zkLend 上的闪电贷款

闪贷是一种无抵押贷款,用户可以在很短的时间内从协议中借入资产,通常是在单笔交易中。如果借款人未能偿还贷款或满足指定条件,整个交易将被撤销,贷款也不会执行。

在 zkLend 的闪贷实现中,有一个独特的捐赠机制。具体来说,用户在偿还资产时,不仅要归还规定的最低金额,还可以捐出额外资金作为捐赠。协议会跟踪这些捐赠资金,并相应地更新 "lending_accumulator"。这一过程在 "thesettle_extra_reserve_balance() "函数中实现。更新 "lending_accumulator "的公式如下:

new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply

  • 储备余额合约中持有的基础代币(如 wstETH)总量,其中包括用户捐赠的代币量。
  • 总债务所有借款用户的债务总额。
  • amount_too_treasury:协议的收入金额。
  • ztoken_supply:份额代币(如 zwstETH)的总供应量。当用户存入 wstETH 时,zkLend ztoken 合约会铸造等量的 zwstETH。

在了解了 zkLend 的核心协议后,我们现在将正式解释攻击者如何通过操纵 lending_accumulatorraw_balance 变量来操纵他们的抵押资产。

0x2 攻击分析

攻击者利用了 zkLend 合同中的以下机制和漏洞来操纵抵押品的价值:

  • 操纵 lending_accumulator**
    • 空市场**:攻击发生前,wstETH 代币的 zkLend 市场是空的,这为操纵提供了完美的条件。此外,zkLend 市场合约允许任何人将任何数量的资产存入空市场。攻击者存入了少量资产,使 "lending_accumulator "值大幅膨胀。
    • 捐赠机制:zkLend 市场合约的 "flash_loan() "函数具有独特的捐赠机制。具体来说,当用户偿还闪贷时,市场合约会计算归还的多余资金,并增加全局 lending_accumulator 变量,从而放大合约中所有用户的抵押品价值。
  • 原始余额 "操作***
    • 滚动行为**:股票代币烧毁过程中的除法操作使用了截断法,这导致在提款过程中低估了用户的 "raw_balance "变化。

通过操纵这两个变量,攻击者能够将抵押品余额增加到超过7,000wstETH,并从市场上借入其他资产牟利。

0x2.1 操纵 "借贷积累器 "变量

0x2.1.1 空市场初始化

通过检查攻击前市场合约的 交易记录,我们可以发现攻击者最初将 ** wei** 的 wstETH 存入 wstETH 市场合约。通过查看这笔交易的内部调用,我们可以发现 wstETH 市场合约持有 0 个 wstETH,zwstETH 的总供应量也是 0

因此,我们可以确认在 zkLend wstETH 市场上没有任何先前的存款或借款。储备余额和令牌供应的初始值都是 0,而借贷积累器的初始值是 1。这种空市场情况为随后的攻击创造了条件,使攻击者可以用极少量的 wstETH 显著放大借贷积累器。

0x2.1.2 通过闪贷操纵 "借贷积累器

接下来,在 this transaction 中,攻击者调用了 flash_loan() 函数,借入 1 wei wstETH 并偿还 1000 wei wstETH。多余的 999 wei 将被视为捐赠,并记录到合约的 "储备余额 "中。

根据 "借贷累积器 "的计算公式,这笔交易使 "借贷累积器 "从 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 后,攻击者调用了市场合约的 deposit() 函数,其中包含 4.069297906051644020 wstETH。根据 "lending_accumulator "的最新值,攻击合约的 "raw_balance "变为2

0x2.2.1 操纵`raw_balance`的第一笔交易

本次交易中,攻击者调用了攻击合约的callflashloandraaan()函数。虽然该合约没有开放源代码,但根据内部调用跟踪,可以推测该函数的逻辑包括一个执行以下操作的循环:

  • 存款:攻击者向市场合约存入一定数量的 wstETH。
  • 提取**:攻击者提取一定数量的 wstETH。
令牌转移记录分析

可以观察到,攻击者存入的 wstETH 金额始终是 "借贷积累器 "的整数倍,例如,是 "借贷积累器 "值(如8.13859)的 2 倍。

但是,提取的 wstETH 金额是lending_accumulator值(例如6.10394)的 1.5 倍。

通过计算,我们可以确定提取的 wstETH 金额超过了存入的金额。为什么会出现这种情况呢?

四舍五入行为

通过查看 deposit()withdraw() 方法的实现,我们可以看到这两个方法分别涉及 zwstETH 的铸造和烧毁。以下是其工作原理:

市场合约中的 "mint() "函数

市场合约中的`burn()`函数

mint() "和 "burn() "进程都包含一个缩放逻辑。缩放逻辑涉及带有下四舍五入的整数除法(向下舍入到最接近的整数),这在漏洞利用中起着关键作用。

当攻击者烧掉一定量的 zwstETH 时,缩放逻辑就会被应用。由于 "lending_accumulator "的操纵值非常高(约为* 4,069,297,906,051,644,020 )*,这种划分会导致攻击者的 "raw_balance "只减少 1 个单位,尽管燃烧了超过 6 个 zwstETH。

下表总结了攻击者的 "原始平衡 "变化:

我们可以观察到,在这笔交易中,攻击者利用 "withdraw() "函数期间的精度损失,反复执行存款-取款逻辑,导致低估了 "raw_balance "差额。最终,用户的 "raw_balance "从2增加到3,获得了一个额外的单位。

0x2.2.2 后续攻击过程

随后的攻击交易遵循与第一次攻击相同的模式:攻击者反复循环存款-取款交易以获取 wstETH。

获得的 wstETH 被重新存入市场,进一步增加了 "raw_balance",导致攻击者的抵押品价值不断上升。

示例说明

我们以下面的 交易 为例进行说明。

  • 总共存入 30 笔资金,每次存入 4.069 wstETH。
  • 总共取款 30 次,每次取款 6.104wstETH。
  • 经过计算,攻击者成功提取了 61.39 wstETH。
  • 此外,值得注意的是,在这些攻击交易之间,还调用了几个 "increase() "方法。这些方法用于将特定数量的 wstETH 从攻击者的账户转移到攻击合约,然后为后续存入市场合约提供资金。

    这些操作提高了raw_balance的值,使攻击者能够继续增加抵押品的价值。最终,攻击者的 "raw_balance "达到了1,724,价值为7,015.4 wstETH,足以从市场上借到其他资产

    0x3 利润分析

    0x3.1 借入其他类型的资金

    在操纵抵押品价值后,攻击者从市场上借入其他类型的资金,并进行了以下交易(摘录):

    0x3.2 将借入资金桥接至第 1 层

    通过检查攻击者的合约 的桥接交易,可以发现攻击者将部分借入资金桥接到了第 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

    Sign up for the latest updates