Back to Blog

第4号:カーブ・インシデント:コンパイラのバグにより、無害なソースコードから誤ったバイトコードが生成される問題

Code Auditing
February 14, 2024
11 min read

2023年7月30日、一連のエクスプロイト(脆弱性攻撃)が複数のCurveプールを標的にし、数百万ドル規模の損失が発生しました。これは典型的なリエントランシー(再入可能)攻撃ですが、根本原因は非典型的であり、リエントランシー保護の欠如を招くコンパイラのバグに起因しています。具体的には、スマートコントラクト内の異なる関数に対するリエントランシーロックが、誤って異なるストレージスロットに割り当てられていたというミスがありました。その結果、Vyperバージョン0.2.15、0.2.16、および0.3.0を使用してコンパイルされたスマートコントラクトが脆弱な状態にありました。

背景

Curveはスマートコントラクトの開発にSolidityではなくVyperを採用しているため、関連する脆弱性を理解しやすくするために、Vyper言語について簡単に紹介します。

Vyperは、イーサリアムの共同創設者であるヴィタリック・ブテリン(Vitalik Buterin)によって作成された、Pythonベースのプログラミング言語です。ドキュメントで紹介されているように、Vyperはイーサリアム仮想マシン(EVM)をターゲットとする、コントラクト指向のドメイン固有の「Python的」なプログラミング言語です。その目標には、シンプルさ、「Pythonらしさ」、セキュリティ、および監査可能性が含まれています。

Vyperは、有名なSolidityに次いで、イーサリアムおよびEVM互換チェーンで2番目に広く使用されているプログラミング言語となっています。CurveはVyper言語の最大の採用企業の1つであり、同社のコントラクトの大半はVyperで記述されています。Curveに関連する、またはCurveからフォークされた多くのプロジェクトも、コードの再利用性を高め、Curveシステムとの相互運用性を確保するためにVyperを使用しています。

以下は、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など)が関数のプロパティを示すために使用され、カスタムデコレータのサポートはありません。

脆弱性分析

この脆弱性は、リエントランシー保護の欠如を招くコンパイラのバグから生じています。

リエントランシー攻撃は、ブロックチェーンエコシステムにおいて最も一般的な攻撃タイプの1つです。具体的には、コントラクトロジックの実行が外部呼び出しを開始し、その一部が再帰的に元のコントラクトを呼び出すことで発生します。これにより、関数の実行中にコントラクトの中間状態が他のコントラクトに対して危険にさらされ、脆弱性につながる可能性があります。これに対抗するため、コントラクトが単一のトランザクション実行中に再入できないように、リエントランシーガード(またはロック)が使用されます。

前述のコードセグメントにおいて、@nonreentrant('lock')という注釈は、remove_liquidity関数が「lock」という名前のリエントランシーロックによって保護されるべきであることを示しています。さらに明確にするために、これをOpenZeppelinのReentrancyGuardコントラクトとその非リエントラント(nonReentrant)修飾子と比較することができるかもしれません。Vyperにおける主な違いは、リエントランシーロックが外部ライブラリによって提供されるのではなく、言語自体の組み込み機能である点です。リエントランシーロックの実装について踏み込んだ調査が行われるまでは、これで十分だと考えられていました。2021年7月23日にマージされたプルリクエスト#2391で導入されたコードセグメントが、set_storage_slots関数を使用して、VyperソースコードのAST(抽象構文木)に基づいてストレージスロットを割り当てていることが判明しました。

def set_storage_slots(vyper_module: vy_ast.Module) -> None:
    """
    モジュールレベルのVyper ASTを解析し、ストレージ変数のレイアウトを計算する。
    """
    # 0からストレージスロットを割り当てる
    # 注: ストレージはバイトアドレス指定ではなく、ワードアドレス指定
    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: リエントランシーキーごとに1バイトまたは1ビットを使用する
            # 入口で追加の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に注目してください。ここで生成されたコードはリエントランシーロックを検証し、ロック状態をストレージに保存しています。しかし、異なる関数が非リエントラントロックに異なるスロットを使用していることが確認されました。test_funcAはスロット0を使用し、test_funcBはスロット1を使用しています。これは、リエントランシーロックが実効性を失っていることを示しており、コントラクトが異なる関数を通じて再入可能(リエントラント)になってしまっています。

攻撃分析

ここでCurveに関する背景を説明します。Curveプールでは、ユーザーはadd_liquidity(流動性追加)およびremove_liquidity(流動性削除)関数を介して流動性を供給および引き出しできます。流動性を追加する場合、追加する量は総供給量に対する比率、具体的には既存の流動性に対する追加流動性の割合によって決定されます。一方、remove_liquidityは、提出されたLP(流動性プロバイダー)トークンと現在の総供給量の比率に基づいて、引き出されるトークンの数を計算し、その後、LPトークンが焼却されます。

さらに、Curveはネイティブトークンを扱うプールをサポートしており、ユーザーにネイティブトークンを返すために低レベル呼び出し(Vyperのraw_call関数)を利用しています。以下のコードセグメントにおいて、remove_liquidity関数は、まずLPトークンの数量と総供給量に基づいて削除すべきトークンを計算して転送し、その後、総供給量が減少されます。

通常の状況であれば、リエントランシーロックがraw call実行中の中間状態の露出を防ぐため、これは安全です。しかし、リエントランシーロックが機能していない場合(最終的に悪用された欠陥)、攻撃が可能になります。機能していないリエントランシーロックは、低レベル呼び出し中に(引き出されるべきトークンは転送済みだが、総供給量はまだ減少していないという)中間状態が脆弱になり、コントラクトへの再入が可能になることを意味します。

@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プールに対する最大規模の攻撃トランザクションの1つである0xa84aa065ceについて検証します。

攻撃のトレースは非常に明確です。

  1. 攻撃者はBalancerからフラッシュローンを借り、40,000 ETHをCurve pETH/ETHプールに流動性として提供し、32,431.41 pETH-ETH LPトークンを受け取りました。
  2. その後、攻撃者は32,431.41 pETH/ETHプールLPトークンを焼却し、プールから3,740 pETHと34,316 ETHを引き出しました。
  3. 流動性削除の過程で、プールコントラクトが再入されました。フォールバック関数内で、攻撃者はさらに40,000 ETHをCurve pETH/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