作者:小白

編集:Liz

背景概要

イーサリアムエコシステムでは、コントラクトアドレスの決定的生成メカニズムが開発者に便利さを提供しますが、同時に新たな攻撃面も導入します。今回のテーマは、CREATE と CREATE2 オペコードを使用して異なる時間に異なるコントラクトを同じアドレスにデプロイする攻撃手法と防御戦略について分析します。以前のスマートコントラクトセキュリティ監査入門記事はコレクションにあります。

前提知識

まず、イーサリアムアドレス生成の2つのルールを理解しましょう:

1. CREATE

CREATE はイーサリアム仮想マシン(EVM)において、スマートコントラクトを動的にデプロイするためのネイティブオペコードです。イーサリアムの創世ブロック以来、すべてのコントラクトのデプロイはこのメカニズムに依存しています。その核心的な特徴は、アドレス生成がデプロイ者アカウントの nonce に依存しているため、アドレスは非決定的であり(デプロイ前に正確に予知できない)、生成されます。

CREATE によって生成されたコントラクトアドレスは、デプロイ者のアドレスとアドレスの nonce によって決定されます:

2. CREATE2

CREATE2 はイーサリアムがコンスタンティノープルハードフォーク(2019 年 2 月)で導入した新しいコントラクト作成オペコード(EIP-1014)です。従来の CREATE とは異なり、コントラクトデプロイ前にオフチェーンの参加者がコントラクトアドレスを事前に計算できるため、オフチェーンのインタラクション(状態チャネルなど)や複雑なコントラクトアーキテクチャが可能となります。

CREATE2 によって生成されるコントラクトアドレスは、以下の4つのパラメータによって決まります:

ここまでで、イーサリアムが生成するコントラクトアドレスの2つの方法を理解したと思います。注意深い読者は、「計算されたコントラクトアドレスが既に存在する場合、どうすればよいか?」と思うかもしれません。この点について心配する必要はありません。イーサリアムでは、CREATE または CREATE2 によって生成されたアドレスが既にチェーン上に存在する場合(外部アカウント EOA またはコントラクトアカウントにかかわらず)、EVM はコントラクト作成のリクエストを拒否します。以下はアドレス衝突の2つのシナリオです:

1. ターゲットアドレスは外部アカウント(EOA)です

  • ルール:ターゲットアドレスが既存の EOA(例えば、ユーザーボレットアドレス)の場合、EVM はコントラクトデプロイリクエストを拒否します。

  • 結果:トランザクションが失敗し、Gas が消費され、コントラクトは作成されず、そのアドレスのデータは上書きされません。

2. ターゲットアドレスはコントラクトアカウントです

  • ルール:ターゲットアドレスがすでにデプロイされたコントラクトアカウントの場合、EVM も同様にデプロイリクエストを拒否します。

  • 結果:トランザクションが失敗し、Gas が消費され、元のコントラクトのコードとストレージデータは保持されます。

もちろん、例外もあります。ターゲットアドレスがコントラクトであり、すでに selfdestruct によって自壊している場合、そのアドレスに新しいコントラクトを再デプロイすることができます。

これで、CREATE と CREATE2 の2つのオペコードのさまざまな特性についての紹介が完了しました。次に、これら2つのオペコードの特性を利用して、コントラクト攻撃を実行する方法を見ていきましょう。

脆弱性の例

脆弱性分析

この DAO コントラクトは基本的なガバナンスメカニズムを実装しています:Owner は approve 関数を通じて提案コントラクトアドレスを Proposals 配列に承認して記録し、後に任意のユーザーが execute 関数を通じて承認された提案を実行できます。一見、厳密な権限管理(Owner のみが提案を承認できる)と実行チェック(提案は承認され、未実行である必要がある)が組み合わさっていますが、実際には隠れた論理的欠陥が存在します:承認された提案アドレスは、実行時にまったく異なるコントラクトコードを指す可能性があります。攻撃者は以下の3つのステップで攻撃を実施できます:

1. 正常なコントラクトをデプロイし、承認を取得します

攻撃者はまず、無害な executeProposal() 関数を含むコントラクト A をデプロイし、Owner によって承認され、そのアドレスを提案リストに追加します。

2. 元のコントラクトを自壊させ、アドレスを占有します。

コントラクト A は自壊操作(selfdestruct)を実行してコードをクリアし、その後攻撃者は CREATE2 オペコードを使用して同じアドレスに悪意のあるコントラクト B(危険なロジックを含む executeProposal())をデプロイします。

3. 実行をトリガーし、制御権を奪います

ユーザーが execute を呼び出すと、コントラクトは delegatecall を通じて新しくデプロイされた悪意のあるコントラクト B のコードを実行します。delegatecall は現在のコントラクトのコンテキストを保持するため、攻撃者はこの操作を通じて DAO コントラクトの状態を改ざん(例えば、Owner を変更する)したり、資産を移転することができます。

次に、攻撃コントラクトを組み合わせて具体的な攻撃プロセスを見ていきます。

攻撃コントラクト

攻撃プロセスは以下の通りです:

1. Alice は DAO コントラクトをデプロイしました。

2. Evil が DeployerDeployer コントラクトをデプロイし、アドレスは DD です。

3. Evil は DD.deploy() を呼び出し、CREATE2 を使用して Deployer をアドレス D にデプロイします(固定 salt)。

4. D.deployProposal() を呼び出し、Proposal コントラクトアドレス P を作成します。この時、D の nonce は 0 です。

5. Alice はアドレス P を承認します。

6. 攻撃者は D.kill() を呼び出し、D を破壊します。この時、アドレス D のアカウントはクリアされ、nonce も 0 にリセットされます。

7. 攻撃者は再び DD.deploy() を呼び出し、Deployer をアドレス D に再デプロイします(同じ CREATE2 パラメータ)。

8. D.deployAttack() を呼び出します。この時、D の nonce が 0 にリセットされ、D のアドレスは変わらないため、作成された Attack コントラクトのアドレスは以前の Proposal コントラクトアドレス P と同じです。

9. この時、DAO の proposals 配列にはアドレス P を指す提案がありますが、現在アドレス P は Attack コントラクトとして再デプロイされています。したがって、execute() を呼び出すと、Attack の executeProposal() が実行され、最終的に delegatecall を通じて DAO の Owner が msg.sender(つまり Attack コントラクト)に変更されます。

攻撃の原理は、攻撃者が CREATE2 を利用してコントラクトを同じアドレスに再デプロイできる特性を利用して、最初に合法的な Proposal コントラクトをデプロイし、DAO に承認させます。その後、Deployer コントラクトを破壊し、同じアドレスに Deployer を再デプロイしてそのアドレスの nonce をリセットし、最終的に悪意のあるコントラクトを以前の Proposal と同じアドレスに成功裏にデプロイします。DAO が保存している提案アドレスは現在悪意のあるコントラクトを指しているため、提案を実行すると、悪意のあるコードが DAO のコンテキストで実行され、Owner が攻撃者に変更されます。

修正提案

開発者として:

  • 提案を承認する際には、アドレスだけでなく、そのアドレスのコードハッシュも記録し、実行時にコードハッシュの一貫性を検証する必要があります。

  • 外部コントラクトを呼び出す際には、十分な安全対策がない限り、delegatecall を使用しないことを避けてください。

  • コントラクトが自壊した後に同じアドレスに再デプロイする可能性を考慮し、外部呼び出し時には外部コントラクトが信頼できるかを確認する必要があります。もし不明な外部コントラクトを呼び出す必要がある場合、そのコントラクトに自壊防止メカニズムが存在するかを確認することをお勧めします。

監査者として:

  • 外部呼び出しのコントラクトに selfdestruct 自壊機能が存在するかどうかを確認します。存在する場合、外部コントラクトが自壊した後に悪意のあるコードを同じアドレスにデプロイする攻撃リスクに警戒する必要があります。

  • コントラクトデプロイ方式が CREATE2 を使用しているかどうかを確認し、生成されるコントラクトアドレスに使用される salt 値が十分にランダムであることを保証します。これにより、コントラクトアドレスが事前に予測されることによって攻撃者がアドレスを先にデプロイして占有することを防ぎます。

  • DAO が提案を実行する際に、ターゲットアドレスのコードの一貫性を検証しているかどうかを確認します。例えば、現在のコードハッシュと承認時のハッシュを比較します。

  • delegatecall の使用シーンを慎重に分析し、ターゲットアドレスが信頼できるか、任意のアドレスからの呼び出しリスクが存在するかを確認します。

  • 提案のライフサイクル管理を審査します。例えば、提案が変更または置き換えられないようにするメカニズムがあるかどうか、承認後にターゲットアドレスの状態をロックするかどうかを確認します。