オリジナルA Deep Dive Into How Curve Pool’s $ 70 Million Reentrancy Exploit Was Possibleオリジナル
』、著者:アン、日常jk編。
最近の Curve プール侵害は、これまでの多くの侵害とは異なり、スマート コントラクト自体の脆弱性と直接関係していたのではなく、それに依存する脆弱性と関連していたため、過去数年間に私たちが目撃したほとんどの暗号通貨ハッキングとは異なります。使用されている言語の基礎となるコンパイラに依存します。
ここでは、イーサリアム仮想マシン (EVM) とインターフェイスするように設計されたスマート コントラクト用の Python プログラミング言語である Vyper について説明します。この脆弱性の背後にあるものに非常に興味があったので、さらに詳しく調べることにしました。
この脆弱性は再入バグと呼ばれ、Vyper プログラミング言語の一部のバージョン、特に v 0.2.15、v 0.2.16、および v 0.3.0 に存在しました。したがって、これらの特定のバージョンの Vyper を使用するすべてのプロジェクトが対象となる可能性があります。
最初のレベルのタイトル
リエントラントとは何ですか?
この脆弱性が発生した理由を理解するには、まずリエントラントとは何か、そしてそれがどのように機能するかを理解する必要があります。
関数は、実行中に中断でき、前の呼び出しの実行が終了する前に安全に再度呼び出すことができる (「リエントラント」) 場合、リエントラントであると言われます。リエントラント関数は、ハードウェア割り込み処理や再帰などのアプリケーションで使用されます。
関数が再入可能であるためには、次の条件を満たす必要があります。
グローバル データや静的データは使用できません。これは単なる慣例であり、厳密な制限はありませんが、グローバル データを使用する関数が中断され、再開されると情報が失われる可能性があります。
自身のコードを変更しないでください。関数が中断された場合は、常に同じ方法で実行できる必要があります。これは管理できますが、一般的にはお勧めできません。
他の非再入可能関数を呼び出すべきではありません。再入可能性とスレッド セーフ性は密接に関連していますが、混同しないでください。関数はスレッドセーフであっても再入可能ではない場合があります。混乱を避けるため、再入可能には 1 つの実行スレッドのみが関与します。これは、マルチタスク オペレーティング システムが存在しなかった時代の概念でした。
i = 5
def non_reentrant_function():
return i** 5
def reentrant_function(number:int):
return number** 5
実際の例を次に示します。
関数 non_reentrant_function:
この関数にはパラメータがありません。
グローバル変数 i の 5 乗を直接返します。
したがって、この関数を呼び出すと、常に 5** 5 ( 3125 ) が返されます。
関数 reentrant_function:
この関数には整数のパラメータ番号があります。
引数の数値の 5 乗を返します。
多くのスマート コントラクト機能はウォレット残高などのグローバル情報にアクセスするため、リエントラントではないことに注意してください。
最初のレベルのタイトル
ロックとは何ですか?
ロックは本質的に、あるプロセスが別のプロセスを要求または「ロック」できるようにするスレッド同期メカニズムです。
最も単純なタイプのロックはバイナリ セマフォと呼ばれます。このロックにより、ロックされたデータへの排他的アクセスが提供されます。データを読み取るための共有アクセスを提供する、より複雑なタイプのロックもあります。プログラミングでロックを誤用すると、プロセスが継続的に相互にブロックし、進行せずに状態が変化するデッドロックまたはライブロックが発生する可能性があります。
@nonreentrant('lock')
def func():
assert not self.locked, "locked"
self.locked = True
# Do stuff
# Release the lock after finishing doing stuff
raw_call(msg.sender, b"", value= 0)
self.locked = False
# More code here
プログラミング言語はバックグラウンドでロックを使用して、複数のサブルーチン間で状態の変更を適切に管理および共有します。ただし、C# や Vyper などの一部の言語では、コード内で直接ロックを使用できます。
上の例では、msg.sender (コントラクトの呼び出し元) が別のコントラクトである場合、実行時にコードが呼び出されないことを確認したいと考えています。 raw_call() の下にロックのないコードがさらにある場合、msg.sender は関数の実行が終了する前に上記のコードをすべて呼び出す可能性があります。
したがって、Vyper では、nonreentrant('lock') デコレーターは、呼び出し元がスマート コントラクト関数の実行が完了する前に繰り返し実行することを防ぐために、関数へのアクセスを制御するメカニズムです。
多くの DeFi ハッキングでは、通常、契約開発者が予期していなかったスマート コントラクトのエラーが発生し、賢いが悪意のある悪用者が一部の機能やデータの漏洩方法の弱点を発見しました。しかし、この事件のユニークな点は、Curve のスマート コントラクト、および攻撃の被害を受けた他のすべてのプールとプロジェクトには、コード自体に既知の脆弱性がないことです。契約はしっかりしている。
nonreentrant('lock') が存在します。
実際にリエントランシー攻撃に対して脆弱なコントラクトを見てみましょう。 @nonreentrant('lock') 修飾子に注目してください。通常、これにより再入が防止されるはずですが、そうではありません。攻撃者は、関数が結果を返す前に、remove_liquidity() を繰り返し呼び出す可能性があります。
@nonreentrant('lock')
def remove_liquidity(
_burn_amount: uint 256,
_min_amounts: uint 256 [N_COINS],
_receiver: address = msg.sender
) -> uint 256 [N_COINS]:
"""
@notice Withdraw coins from the pool
@dev Withdrawal amounts are based on current deposit ratios
@param _burn_amount Quantity of LP tokens to burn in the withdrawal
@param _min_amounts Minimum amounts of underlying coins to receive
@param _receiver Address that receives the withdrawn coins
@return List of amounts of coins that were withdrawn
"""
total_supply: uint 256 = self.totalSupply
amounts: uint 256 [N_COINS] = empty(uint 256 [N_COINS])
for i in range(N_COINS):
old_balance: uint 256 = self.balances[i]
value: uint 256 = 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, uint 256)"),
convert(_receiver, bytes 32),
convert(value, bytes 32),
),
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(uint 256 [N_COINS]), total_supply)
return amounts
最初のレベルのタイトル
これはどのように悪用されるのでしょうか?
これまでのところ、リエントラント攻撃とは、スマート コントラクト内の特定の関数を繰り返し呼び出す手法であることがわかっています。しかし、これがどのようにして資金の盗難とカーブ攻撃による 7,000 万ドルの損失につながったのでしょうか?
スマート コントラクトの最後にある self.balanceOf[msg.sender] -= _burn_amount に注目してください。これにより、プール内の msg.sender の流動性からバーン手数料を差し引いたものがスマート コントラクトに伝えられます。次のコード行は、 message.sender で transfer() を呼び出します。
したがって、悪意のある契約では、金額が更新される前に継続的に引き出しを呼び出し、プール内の流動性をほぼすべて引き出してしまう可能性があります。
このような攻撃の通常の流れは次のとおりです。
脆弱なコントラクトには 10 eth があります。
攻撃者はデポジットを呼び出し、1 イーサリアムをデポジットします。
攻撃者は 1 eth を引き出すよう呼び出します。このとき、引き出し関数はいくつかのチェックを実行します。
攻撃者は自分のアカウントに 1 イーサリアムを持っていますか?はい。
悪意のあるコントラクトに 1 eth を転送します。注: 関数はまだ実行中であるため、契約の残高は変更されていません。
攻撃者は再び 1 eth を引き出すよう電話をかけます。 (再入場)
攻撃者は自分のアカウントに 1 イーサリアムを持っていますか?はい。
これは、プール内の流動性がなくなるまで繰り返されます。
