#4:曲线事件:编译器错误导致无辜源代码产生错误字节码

曲线事件:编译器错误导致无害源代码生成错误字节码

#4:曲线事件:编译器错误导致无辜源代码产生错误字节码

2023年7月30日,一系列漏洞利用攻击了多个Curve池,导致数百万美元的损失。这是一次典型的重入攻击,但其根本原因非同寻常,源于编译器的一个bug,导致重入保护缺失。具体来说,是在智能合约中,不同函数重入锁被分配了不同的存储槽。因此,使用Vyper版本0.2.15、0.2.16和0.3.0编译的智能合约存在漏洞。

背景

由于Curve使用Vyper而非Solidity进行智能合约开发,因此简要介绍Vyper语言以帮助理解相关漏洞。

Vyper是由以太坊联合创始人Vitalik Buterin创建的基于Python的编程语言。如其文档所示,Vyper是一种面向合约、领域特定、Pythonic的编程语言,以以太坊虚拟机(EVM)为目标。其目标包括简洁性、“Pythonicity”、安全性和可审计性。

Vyper已成为以太坊和EVM兼容链上使用第二广泛的编程语言,仅次于著名的Solidity。Curve是Vyper语言的最大采用者之一,其大部分合约都用Vyper编写。许多与Curve相关或从Curve分叉的项目也使用Vyper,以确保更好的代码重用性和与Curve系统的互操作性。

下面是一段来自Curve池的代码片段(本事件中被攻击的pETH/ETH池)。尽管语法与Python非常相似,但Vyper与Python之间存在显著差异:

@external
@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint256,
    _min_amounts: uint256[N_COINS],
    _receiver: address = msg.sender
) -> uint256[N_COINS]:
    """
    @notice 从池中提取代币
    @dev 提款金额基于当前存款比例
    @param _burn_amount 提款时要销毁的LP代币数量
    @param _min_amounts 预期收到的底层代币的最小数量
    @param _receiver 接收提取代币的地址
    @return 提取的代币数量列表
    """
    total_supply: uint256 = self.totalSupply
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])

    for i in range(N_COINS):
        old_balance: uint256 = self.balances[i]
        value: uint256 = old_balance * _burn_amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
        self.balances[i] = old_balance - value
        amounts[i] = value

        if i == 0:
            raw_call(_receiver, b"", value=value)

合约中的所有函数参数和返回值都必须正确地进行类型注解。该语言不支持类或继承;关键字'self'仅用于指代合约本身以及访问状态变量。内置装饰器(例如@external和@nonreentrant)用于表示函数的属性,不支持自定义装饰器。

漏洞分析

漏洞源于编译器的一个bug,导致重入保护缺失。

重入攻击是区块链生态系统中一种非常常见的攻击类型。具体来说,当合约逻辑的执行触发外部调用时,其中一些外部调用可能会递归地回溯调用原始合约。这会在函数执行期间将合约的中间状态危险地暴露给其他合约,可能导致漏洞。为了应对这种情况,通常会采用重入守卫(或锁)来确保在单个事务执行期间合约不能被重入。

在前面提到的代码片段中,@nonreentrant('lock')注解表示remove_liquidity函数应该使用名为lock的重入锁进行保护。为了更清晰地理解,可以将其与OpenZeppelin ReentrancyGuard合约及其nonReentrant修饰符进行比较。Vyper的主要区别在于,重入锁不是由外部库提供的,而是语言本身的内置功能。直到深入研究重入锁的实现后,才发现这一点似乎不足以应对。发现在2021年7月23日合并的Pull Request #2391中引入的代码段,利用set_storage_slots函数根据Vyper源代码的AST(抽象语法树)来分配存储槽。

def set_storage_slots(vyper_module: vy_ast.Module) -> None:
    """
    解析模块级别的Vyper AST以计算存储变量的布局。
    """
    # 从0开始分配存储槽
    # 注意:存储是字(word)可寻址的,而非字节可寻址
    storage_slot = 0

    for node in vyper_module.get_children(vy_ast.FunctionDef):
        type_ = node._metadata["type"]
        if type_.nonreentrant is not None:
            type_.set_reentrancy_key_position(StorageSlot(storage_slot))
            # TODO:为每个重入键使用一个字节 - 或位
            # 这需要额外的SLOAD或在入口处将位置值缓存在内存中
            storage_slot += 1

然而,一个关键的错误在于,对于每一个重入锁,存储槽都会加1。因此,不同的函数最终会被分配唯一的存储槽作为其重入锁。为了评估这种实现,我们编译以下合约:

# 编译命令:vyper -f bytecode,bytecode_runtime,ir test_contract.vy

from vyper.interfaces import ERC20

test_addr: address

@external
def __init__(_addr: address):
    self.test_addr = _addr

@external
@nonreentrant('lock')
def test_funcA():
    pass

@external
@nonreentrant('lock')
def test_funcB():
    pass

使用Vyper编译器版本0.2.16+commit.59e1bdd,代码编译过程中生成的Vyper IR(中间表示)部分列出如下:

[if,
  [eq, _func_sig, 2354224227 <test_funcA()>],
  [seq,
    [assert, [iszero, [sload, 0]]],
    [sstore, 0, 1],
    pass,
    # Line 13
    pass,
    # Line 12
    [sstore, 0, 0],
    stop]],
# Line 17
[if,
  [eq, _func_sig, 741100118 <test_funcB()>],
  [seq,
    [assert, [iszero, [sload, 1]]],
    [sstore, 1, 1],

让我们关注第4-5行和第16-17行的IR,其中生成的代码验证重入锁并将锁状态存储在存储中。然而,我们注意到不同的函数使用了不同的非重入锁槽:test_funcA使用槽0,而test_funcB使用槽1。这表明重入锁无效,因为可以通过不同的函数重入合约。

攻击分析

这里我们提供一些关于Curve的背景信息。Curve池允许用户通过add_liquidity和remove_liquidity函数提供和提取流动性。在添加流动性时,要添加的数量由总供应量的比例决定,具体而言,是添加的流动性占现有流动性的比例。另一方面,remove_liquidity在销毁LP代币后,根据提交的LP(流动性提供者)代币数量与当前总供应量的比例来计算要提取的代币数量。

此外,Curve支持处理原生代币的池,并使用低级调用(Vyper中的raw_call函数)将原生代币返还给用户。在下面的代码片段中,remove_liquidity函数首先根据LP代币数量和总供应量计算并转移要提取的代币,然后Subsequently,总供应量被减少。

在正常情况下,这是安全的,因为重入锁应该可以防止在raw调用期间暴露中间状态。然而,当重入锁无效时——一个最终被利用的缺陷——攻击就变得可行了。无效的重入锁意味着中间状态(此时要提取的代币已经转出,但总供应量尚未减少)在低级调用期间变得脆弱,允许潜在的重入合约。

@external
@nonreentrant('lock')
def remove_liquidity(
    _burn_amount: uint256,
    _min_amounts: uint256[N_COINS],
    _receiver: address = msg.sender
) -> uint256[N_COINS]:
    """
    @notice 从池中提取代币
    @dev 提款金额基于当前存款比例
    @param _burn_amount 提款时要销毁的LP代币数量
    @param _min_amounts 预期收到的底层代币的最小数量
    @param _receiver 接收提取代币的地址
    @return 提取的代币数量列表
    """
    total_supply: uint256 = self.totalSupply
    amounts: uint256[N_COINS] = empty(uint256[N_COINS])

    for i in range(N_COINS):
        old_balance: uint256 = self.balances[i]
        value: uint256 = old_balance * _burn_amount / total_supply
        assert value >= _min_amounts[i], "Withdrawal resulted in fewer coins than expected"
        self.balances[i] = old_balance - value
        amounts[i] = value

        if i == 0:
            raw_call(_receiver, b"", value=value)
        else:
            response: Bytes[32] = raw_call(
                self.coins[1],
                concat(
                    method_id("transfer(address,uint256)"),
                    convert(_receiver, bytes32),
                    convert(value, bytes32),
                ),
                max_outsize=32,
            )
            if len(response) > 0:
                assert convert(response, bool)

    total_supply -= _burn_amount
    self.balanceOf[msg.sender] -= _burn_amount
    self.totalSupply = total_supply
    log Transfer(msg.sender, ZERO_ADDRESS, _burn_amount)

    log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply)

    return amounts

由于总供应量尚未减少(如上面第27行所示),因此可以利用这种重入。如果在此时重入合约并调用add_liquidity,流动性提供将基于不正确(偏高)的总供应量,导致过量铸造LP代币,从而使池遭受损失。本次事件中的大多数攻击都利用了这一漏洞。以下讨论分析了针对Curve pETH-ETH池的、最大的攻击交易之一,交易哈希为0xa84aa065ce。

攻击流程非常清晰。

  1. 攻击者从Balancer借入了一个闪电贷,然后向Curve pETH/ETH池提供了40,000 ETH作为流动性,获得了32,431.41 pETH-ETH LP代币。
  2. 攻击者随后通过销毁32,431.41 pETH-ETH池LP代币,从池中提取了3,740 pETH和34,316 ETH。
  3. 在流动性提取过程中,池合约被重入。在fallback函数中,攻击者向Curve pETH/ETH池提供了另外40,000 ETH作为流动性,铸造了额外的82,182 LP代币。在此过程中,使用的总供应量数据是在流动性提取之前的数据,这是不正确的,导致铸造的LP代币数量超出预期。
  4. 随后,攻击者通过销毁10,272.84 Curve LP代币,提取了1,184.73 pETH和47,506.53 ETH。总而言之,攻击者通过铸造额外的LP代币并利用这些额外LP代币耗尽池子而获利。

总结

此漏洞源于编译器,而非源代码。这是编译器bug首次导致区块链生态系统遭受重大经济损失。

鉴于编译器是关键基础设施组件,其安全性对于区块链技术的完整性和功能至关重要。编译器相关的问题可能不会立即显现,但它们可能产生广泛而严重的影响。确保编译器的安全需要严格的评估,这应该包括全面的审计和健全的漏洞赏金计划,以发现和解决漏洞。编译器bug固有的微妙性使其检测和缓解工作复杂。这种复杂性凸显了复杂的攻击检测和预防机制的重要性,例如BlockSec的BlockSec Phalcon提供的机制,它们提供必要的自动化防御来有效保护DeFi协议。

阅读本系列其他文章:

Sign up for the latest updates