Back to Blog

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

Code Auditing
February 14, 2024

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
FATF’s New Stablecoin Report Signals a Shift to Secondary-Market Compliance
Knowledge

FATF’s New Stablecoin Report Signals a Shift to Secondary-Market Compliance

BlockSec interprets FATF’s March 2026 report on stablecoins and unhosted wallets, explains why supervision is shifting toward secondary-market P2P activity, breaks down the report’s main recommendations and red flags, and shows how on-chain monitoring, screening, and cross-chain tracing can help issuers and VASPs respond with stronger, more effective compliance controls.

Weekly Web3 Security Incident Roundup | Mar 16 – Mar 22, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Mar 16 – Mar 22, 2026

This BlockSec weekly security report covers seven DeFi attack incidents detected between March 16 and March 22, 2026, across Ethereum, BNB Chain, Polygon, and Polygon zkEVM, with total estimated losses of approximately $82.7M. The most significant event was the Resolv stablecoin protocol's infrastructure-key compromise, which led to over $80M in unauthorized USR minting and cross-protocol contagion across lending markets. Other incidents include a $2.15M donation attack combined with market manipulation on Venus Protocol, a $257K empty-market exploit on dTRINITY (Aave V3 fork), access control vulnerabilities in Fun.xyz and ShiMama, a weak-randomness exploit in BlindBox, and a redemption accounting flaw in Keom.

Weekly Web3 Security Incident Roundup | Mar 9 – Mar 15, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Mar 9 – Mar 15, 2026

This BlockSec weekly security report covers eight DeFi attack incidents detected between March 9 and March 15, 2026, across Ethereum and BNB Chain, with total estimated losses of approximately $1.66M. Incidents include a $1.01M AAVE incorrect liquidation caused by oracle misconfiguration, a $242K exploit on the deflationary token MT due to flawed trading restrictions, a $149K exploit on the burn-to-earn protocol DBXen from `_msgSender()` and `msg.sender` inconsistency, and a $131K attack on AM Token exploiting a flawed delayed-burn mechanism. The report provides detailed vulnerability analysis and attack transaction breakdowns for each incident.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit