Back to Blog

#4:曲線事件:編譯器錯誤導致無害原始碼生成錯誤字節碼

Code Auditing
February 14, 2024
11 min read

2023 年 7 月 30 日,一系列攻擊針對多個 Curve 資金池,導致了數百萬美元的損失。這是一起典型的重入攻擊(Reentrancy attack),但其根本原因並不典型,源於一個編譯器錯誤,導致了重入保護失效。具體而言,錯誤在於智能合約中不同函數的重入鎖被分配了不同的儲存槽位。因此,使用 Vyper 版本 0.2.15、0.2.16 和 0.3.0 編譯的智能合約均存在漏洞。

背景

由於 Curve 使用 Vyper 而非 Solidity 進行智能合約開發,為了幫助理解相關漏洞,在此簡要介紹 Vyper 語言。

Vyper 是一門由以太坊聯合創始人 Vitalik Buterin 創建的基於 Python 的程式語言。正如其文件所述,Vyper 是針對以太坊虛擬機(EVM)的、面向合約的、特定領域的、具備 Python 風格的程式語言。其目標包括簡潔性、「Python 風格」、安全性和可審計性。

Vyper 已成為繼著名的 Solidity 之後,以太坊及 EVM 兼容鏈上第二大廣泛使用的程式語言。Curve 是 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], "提取結果少於預期代幣數量"
        self.balances[i] = old_balance - value
        amounts[i] = value

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

合約內函數的所有參數和返回值都必須有適當的類型註解。該語言不支持類或繼承;「self」關鍵字僅用於引用合約本身以及訪問狀態變量。內建裝飾器(例如 @external 和 @nonreentrant)用於表示函數的屬性,且不支持自定義裝飾器。

漏洞分析

該漏洞源於編譯器錯誤,導致缺乏 重入(reentrancy) 保護。

重入攻擊是區塊鏈生態系統中最常見的攻擊類型之一。具體而言,當合約邏輯的執行啟動了外部調用時,其中一些調用可能會遞歸地回調原始合約。這可能會在函數執行期間將合約的中間狀態危險地暴露給其他合約,從而導致漏洞。為了應對這一點,採用重入防護(或稱「鎖」)以確保合約在單筆交易執行期間無法被重入。

在上述代碼段中,@nonreentrant('lock') 註解表明 remove_liquidity 函數應該使用名為 lock 的重入鎖進行保護。為了更清晰,我們可以將其與 OpenZeppelin 的 ReentrancyGuard 合約及其 nonReentrant 修飾符進行比較。Vyper 的主要區別在於,重入鎖並非由外部庫提供,而是語言本身的內建功能。在深入研究重入鎖的實現之前,這看起來似乎沒有問題。研究發現,Pull Request #2391(於 2021 年 7 月 23 日合併)中引入的代碼段利用 set_storage_slots 函數根據 Vyper 源代碼的抽象語法樹(AST)來分配儲存槽位。

def set_storage_slots(vyper_module: vy_ast.Module) -> None:
    """
    解析模塊級的 Vyper AST 以計算儲存變量的佈局。
    """
    # 從 0 開始分配儲存槽位
    # 注意儲存是按字(word)定址,而非按字節(byte)定址
    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,
    # 第 13 行
    pass,
    # 第 12 行
    [sstore, 0, 0],
    stop]],
# 第 17 行
[if,
  [eq, _func_sig, 741100118 <test_funcB()>],
  [seq,
    [assert, [iszero, [sload, 1]]],
    [sstore, 1, 1],

讓我們聚焦於第 4-5 行和第 16-17 行的 IR,其中生成的代碼驗證了重入鎖並將鎖狀態存儲在儲存中。然而,觀察到不同的函數對 non-reentrant 鎖使用了不同的槽位:test_funcA 使用槽位 0,而 test_funcB 使用槽位 1。這表明重入鎖是無效的,因為合約可以通過不同的函數被重入。

攻擊分析

在此,我們提供一些關於 Curve 的背景信息。Curve 資金池允許用戶通過 add_liquidity 和 remove_liquidity 函數供應和提取流動性。添加流動性時,添加量由總供應量的比例決定,具體而言,是添加的流動性佔現有流動性的比例。另一方面,remove_liquidity 根據提交的 LP(流動性提供者)代幣與當前總供應量的比例計算要提取的代幣數量,隨後銷毀 LP 代幣。

此外,Curve 支持處理原生代幣的資金池,並使用低級調用(Vyper 中的 raw_call 函數)將原生代幣返回給用戶。在下面的代碼段中,remove_liquidity 函數首先根據 LP 代幣數量和總供應量計算並轉移要移除的代幣,然後總供應量隨後減少。

在正常情況下,這應該是安全的,因為重入鎖應該防止在低級調用期間暴露中間狀態。然而,當重入鎖無效時——這個缺陷最終遭到了利用——攻擊就變得可行了。無效的重入鎖意味著中間狀態(已轉移出待提取的代幣,但總供應量尚未減少)在低級調用期間變得脆弱,從而允許對合約進行潛在的重入。

@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], "提取結果少於預期代幣數量"
        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. 在流動性移除期間,資金池合約被重入。在回調函數中,攻擊者又向 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 代幣並使用這些額外的代幣抽乾資金池來獲利。

總結

此漏洞源於編譯器,而非原始代碼。這標誌著區塊鏈生態系統內首次因編譯器錯誤而導致重大的經濟損失。

鑑於編譯器構成了關鍵的基礎設施組件,其安全性對於區塊鏈技術的完整性和功能性至關重要。編譯器相關的問題可能不會立即顯現,但它們可能會產生廣泛而嚴重的後果。確保編譯器安全需要嚴格的評估,其中包括全面的審計和強健的漏洞賞金計劃,以發現並解決漏洞。編譯器錯誤固有的微小性使得其檢測和緩解變得複雜。這種複雜性凸顯了先進的攻擊檢測和防禦機制的重要性,例如 BlockSec 的 BlockSec Phalcon 所提供的機制,它們提供必要的自動化防禦,以有效保護 DeFi 協議。

閱讀本系列的其它文章:

Best Security Auditor for Web3

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

BlockSec Audit