币圈知识百科

深度解析:Curve协议7000万美元失窃背后的重入攻击原理

近期,由于Vyper编译语言的漏洞,导致超过7000万美元的资金在Curve中被盗。这一事件被定性为「重入攻击」,本文将对此进行深入解析。本文由CyberPunkMetalHead撰写于Medium平台文章《》,经星球日报Odaily编译整理。

本次Curve池遭遇的漏洞与我们在过去几年中目睹的大部分加密货币黑客事件存在显著差异。不同于以往直接源于智能合约代码本身的缺陷,此次事故的根源在于其开发所使用的编程语言底层的编译器问题。

这里涉及的核心技术是 Vyper:一种面向以太坊虚拟机(EVM)交互、具有Pythonic风格的智能合约编程语言。出于对此次漏洞成因的好奇,我决定深入探究其背后的技术细节。

随着漏洞影响的扩大,每日的新闻头条都在更新最新的损失数字。目前局势虽已初步可控,但在此之前已有逾7000万美元被盗。据LlamaRisk事后评估,截至发文时,包括PEGD的pETH/ETH(1100万美元)、Metronome的msETH/ETH(340万美元)、Alchemix的alETH/ETH(2260万美元)以及Curve DAO自身(约2470万美元)在内的多个DeFi项目池子均遭到黑客渗透。

此类漏洞被称为重入错误,主要存在于Vyper编程语言的特定历史版本中,特别是v0.2.15、v0.2.16和v0.3.0。这意味着,所有使用上述特定版本Vyper构建的项目,理论上都面临着潜在的攻击风险。

什么是重入(reentrancy)?

要理解此次漏洞的发生机理,我们首先需要明确「重入」的概念及其运作方式。

若一个函数在执行过程中允许被中断,且在该次调用尚未完成时能够安全地再次被调用(即「重新进入」),则该函数被视为可重入函数。这类特性常见于硬件中断处理、递归算法等应用场景中。

要使一个函数具备可重入性,通常需满足以下条件:

  • 避免依赖全局变量或静态数据。这更多是一种编程约定而非硬性限制,但若使用全局数据的函数被中断并重启,可能会导致信息丢失。
  • 不得修改自身的代码逻辑。无论函数何时被中断,都应保证能以相同的方式继续运行。虽然技术上可行,但通常不建议这样做。
  • 不应调用其他不可重入的函数。需注意,重入性与线程安全虽紧密相关,但概念不同。一个函数可以是线程安全的,却不一定可重入。为避免混淆,重入性仅针对单线程执行环境而言,这是早期多任务操作系统出现前的一个经典概念。

以下是一个直观的示例:

i = 5
def non_reentrant_function():
return i**5
def reentrant_function(number:int):
return number**5

关于函数 non_reentrant_function

  • 该函数不接受任何参数。
  • 它直接返回全局变量 i 的五次方结果。
  • 因此,无论何时调用此函数,返回值恒定为 5**5,即 3125。

关于函数 reentrant_function

  • 该函数接收一个整型参数 number
  • 它返回该参数的五次方值。
  • 这意味着你可以传入任意整数获取相应结果。例如,传入2则返回 2**5,即32。

值得注意的是,许多智能合约函数并非可重入的,因为它们往往需要访问如钱包余额等全局状态信息。

什么是锁(Lock)?

锁本质上是一种用于线程同步的机制,允许某个进程对另一进程进行声明或「锁定」操作。

最基础的锁类型是二进制信号量,它为被锁定的资源提供独占式访问权限。此外还有更复杂的锁类型,支持对读取操作的共享访问。在编程实践中,误用锁可能导致死锁或活锁现象,即进程相互阻塞,状态不断变换却无法取得进展。

大多数编程语言在后台隐式使用锁来优雅地管理多个子程序间的状态变更。然而,像C#和Vyper这样的语言允许开发者在代码层面直接使用锁机制。

在上述语境中,我们需要确保当 msg.sender(即合约调用者)为另一个合约时,不会在其运行期间触发额外的代码调用。如果 raw_call() 下方仍有后续代码且未加锁保护,msg.sender 可能会在我们当前函数执行完毕前,提前触发上述所有代码逻辑。

因此,在Vyper中,@nonreentrant(‘lock’) 装饰器旨在作为一种访问控制机制,防止调用者在当前函数执行结束前反复触发智能合约函数。

回顾过往诸多DeFi黑客案例,通常都是由于合约开发者未能预见某些函数或数据暴露方式的弱点,而被恶意利用者钻了空子。但此次事件颇具独特性:Curve的智能合约以及其他受害池和项目,其代码本身并未发现已知漏洞,合约结构是稳固的。

关键在于:nonreentrant(‘lock’) 确实存在于代码中。

问题的症结在于Vyper语言在处理重入锁时的内部逻辑出现了偏差。因此,尽管合约创建者部署了看似合理的代码,但由于编译器未能正确实施锁机制,导致攻击者得以利用这个有缺陷的锁,从而引发合约行为偏离预期。

让我们审视真正遭受重入攻击的合约实例。请注意其中的 @nonreentrant(‘lock’) 修饰符吗?按理说,这应当能有效阻止重入,但在实际运行中却失效了。攻击者成功地在函数返回结果之前,反复调用了 remove_liquidity()

这是如何被利用的?

至此,我们已经了解重入攻击是通过反复调用智能合约中的特定函数来实现的。那么,这种手法是如何具体导致资金被盗,并在Curve事件中造成7000万美元巨额损失的?

请留意智能合约末尾的一行代码:self.balanceOf[msg.sender] -= _burn_amount。这行代码指示智能合约从 msg.sender 的流动性余额中扣除相应的燃烧费用。紧接着的代码则是向 msg.sender 发送转账指令 transfer()

这就产生了一个时间窗口:恶意合约可以在余额更新操作执行之前,持续不断地调用提现功能,从而几乎可以随意提取池中的全部流动性。

此类攻击的标准流程通常如下所示:

  • 易受攻击的合约初始拥有 10 个 eth。
  • 攻击者发起存款操作,存入 1 个 eth。
  • 攻击者发起提现请求,提取 1 个 eth。此时,提现函数开始执行一系列检查:
  • 检查攻击者账户是否拥有至少 1 个 eth?确认无误。
  • 将 1 个 eth 转移至攻击者的恶意合约地址。注意:此时合约内的余额记录尚未更新,因为该函数仍在运行中。
  • 攻击者再次立即发起提现请求,提取 1 个 eth。(即发生「重入」)
  • 再次检查攻击者账户是否拥有至少 1 个 eth?由于余额尚未扣减,系统判定仍然拥有。

这一过程将循环往复,直至池内耗尽所有流动性为止。

Vyper语言中的这一缺陷已在后续版本中得到修复,自 0.3.0 版本起不再存在此问题。如果您是开发人员,或是使用Vyper技术的Web3组织成员,请务必立即将您的环境更新至最新版本,以杜绝安全隐患。