2023年7月30日、複数のCurveプールがエクスプロイトの標的となり、数百万ドルの損失が発生しました。これは典型的なリエントランシー攻撃ですが、コンパイラバグが原因でリエントランシー保護が存在しないという非典型的な根本原因に起因しています。具体的には、スマートコントラクト内の異なる関数のリエントランシーロックが異なるストレージスロットに割り当てられるという間違いがありました。その結果、Vyperバージョン0.2.15、0.2.16、および0.3.0でコンパイルされたスマートコントラクトが脆弱になりました。
背景
Curveはスマートコントラクト開発にSolidityではなくVyperを使用しているため、関連する脆弱性の理解を助けるためにVyper言語の簡単な紹介を行います。
Vyperは、Ethereumの共同創設者であるVitalik Buterinによって作成されたPythonベースのプログラミング言語です。ドキュメントで紹介されているように、VyperはEthereum Virtual Machine(EVM)をターゲットとした、コントラクト指向のドメイン固有のPythonicプログラミング言語です。その目標には、シンプルさ、「Pythonらしさ」、セキュリティ、および監査可能性が含まれます。
Vyperは、有名なSolidityに次いで、EthereumおよびEVM互換チェーンで2番目に広く使用されているプログラミング言語となっています。CurveはこのVyper言語の最大の採用者の一つであり、ほとんどのコントラクトが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], "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)は関数のプロパティを示すために使用され、カスタムデコレータはサポートされていません。
脆弱性分析
脆弱性は、コンパイラバグに起因しており、リエントランシー保護の欠如につながっています。
リエントランシー攻撃は、ブロックチェーンエコシステム内で最も一般的な攻撃タイプの一つです。具体的には、コントラクトロジックの実行が外部呼び出しをトリガーし、その一部が元のコントラクトに再帰的に呼び戻される場合に発生します。これにより、関数の実行中にコントラクトの中間状態が他のコントラクトに危険にさらされ、脆弱性につながる可能性があります。これに対抗するために、リエントランシーガード、またはロックが使用され、単一のトランザクションの実行中にコントラクトが再入力されないことを保証します。
前述のコードスニペットでは、@nonreentrant('lock') アノテーションは、remove_liquidity 関数が lock という名前のリエントランシーロックで保護されるべきであることを示しています。さらに明確にするために、OpenZeppelin ReentrancyGuard コントラクトとその nonReentrant モディファイアと比較することができます。Vyperの主な違いは、リエントランシーロックが外部ライブラリによって提供されるのではなく、言語自体の組み込み機能であることです。リエントランシーロックの実装をさらに調査するまで、これは満足のいくものと思われました。Pull Request #2391(2021年7月23日にマージ)で導入されたコードは、VyperソースコードのAST(Abstract Syntax Tree)に基づいてストレージスロットを割り当てるためにset_storage_slots関数を使用していたことが判明しました。
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 Compilerバージョン0.2.16+commit.59e1bddを使用して、コードコンパイル中に生成されたVyper IR(Intermediate Representation)の一部を以下に示します。
[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],
Lines 4-5およびLines 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], "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
上記のLine 27に示すように総供給量が削減されていないため、このリエントランシーを悪用できます。この時点でコントラクトに再入力し、add_liquidityを呼び出すと、供給量は誤った総供給量(本来より高い)に基づいて計算され、過剰なLPトークンが鋳造され、プールに損失が生じます。このインシデントのほとんどの攻撃はこの脆弱性を悪用しています。以下は、Curve pETH-ETHプールに対する最大の攻撃トランザクションの一つである0xa84aa065ceを検討します。

攻撃トレースは非常に明確です。
- 攻撃者はBalancerからフラッシュローンを借り入れ、その後Curve pETH/ETHプールに40,000 ETHを流動性として提供し、32,431.41 pETH-ETH LPトークンを受け取りました。
- 攻撃者はその後、32,431.41 pETH-ETHプールLPトークンを償却することにより、プールから3,740 pETHと34,316 ETHを引き出しました。
- 流動性引き出し中に、プールコントラクトが再入力されました。フォールバック関数内で、攻撃者はCurve pETH/ETHプールにさらに40,000 ETHを流動性として提供し、追加で82,182 LPトークンを鋳造しました。このプロセス中、流動性引き出し前の総供給量(誤った値)が使用され、本来可能な数よりも多くのLPトークンが鋳造されました。
- その後、攻撃者は10,272.84 Curve LPトークンを償却することにより、1,184.73 pETHと47,506.53 ETHを引き出しました。要約すると、攻撃者は追加のLPトークンを鋳造し、これらの追加LPトークンを使用してプールを枯渇させることで利益を得ました。
まとめ
この脆弱性は、ソースコードではなくコンパイラに起因するものでした。ブロックチェーンエコシステム内でコンパイラバグが大きな金銭的損失を引き起こしたのはこれが初めてです。
Indeed, smart contracts compiled using Vyper versions 0.2.15, 0.2.16, and 0.3.0 are vulnerable, which can lead to the failure of the reentrancy guard. https://t.co/GM7Ze5to39 pic.twitter.com/K6Lo29Pfn2
— BlockSec (@BlockSecTeam) July 30, 2023
コンパイラは重要なインフラストラクチャコンポーネントであるため、そのセキュリティはブロックチェーン技術の整合性と機能性にとって極めて重要です。コンパイラ関連の問題はすぐに明らかにならないかもしれませんが、広範で深刻な結果をもたらす可能性があります。コンパイラの保護には、脆弱性を発見して解決するために、包括的な監査と堅牢なバグバウンティプログラムを含む厳格な評価が必要です。コンパイラバグの固有の微妙さは、それらの検出と緩和を複雑にします。この複雑さは、BlockSecのBlockSec Phalconが提供するような洗練された攻撃検出および防止メカニズムの重要性を高めており、DeFiプロトコルを効果的に保護するための不可欠な自動防御を提供します。
このシリーズの他の記事を読む:
- リードイン:2023年のトップ10「素晴らしい」セキュリティインシデント
- #1:Flashbots Relayの脆弱性を悪用したMEVボットの収穫
- #2:Euler Financeインシデント:2023年最大のハック
- #3:KyberSwapインシデント:極めて微妙な計算による丸め誤差の巧妙な悪用
- #5:Platypus Finance:幸運にも3回の攻撃を生き延びる
- #6:Hundred Financeインシデント:脆弱なフォークされたプロトコルにおける精度関連エクスプロイトの波を触媒する
- #7:ParaSpaceインシデント:業界で最も重大な攻撃を阻止するための時間との戦い
- #8:SushiSwapインシデント:不器用な救助の試みが一連のコピーキャット攻撃を招く
- #9:MEVボット0xd61492:捕食者から獲物へ、巧妙なエクスプロイト
- 10:ThirdWebインシデント:信頼されたモジュール間の互換性が脆弱性を露呈



