メインコンテンツへスキップ
Change page

スマートコントラクトのセキュリティ

最終編集者: @HiroyukiNaito(opens in a new tab), 2024年5月22日

スマートコントラクトは、非常に柔軟性が高く、大量の値やデータを制御できる能力があり、ブロックチェーン上のコードに基づきイミュータブルな(不変の)ロジックを実行します。 これにより、トラストレスな分散型アプリケーション(Dapp)による活気に満ちたエコシステムが構築され、レガシーシステムと比べて多くの利点をもたらしています。 しかし、これはスマートコントラクトの脆弱性を悪用して利益を得ようとしている攻撃者に機会を与えてしまうことにもなります。

イーサリアムのようなパブリックブロックチェーンは、スマートコントラクトのセキュリティ確保の問題をさらに複雑にします。 デプロイされたコントラクトのコードは通常、セキュリティ上の欠陥にパッチを当てるために変更することはできません。一方、スマートコントラクトから盗まれた資産は追跡が非常に難しく、その不変性により、大抵回収できません。

数値は一様ではありませんが、スマートコントラクトのセキュリティ上の欠陥が原因で盗まれたり失われたりした価値の総額は、10億ドルを超えると推定されています。 これには、The DAOハック(opens in a new tab)(現行価格10億ドル相当以上の360万ETHの盗難)、パリティ(Parity)マルチシグウォレットハック(opens in a new tab) (ハッカーによる3000万ドルの盗難)、パリティ(Parity)凍結ウォレット問題(opens in a new tab)(3億ドル以上のETHを永遠にロック)などの有名な事件も含まれています。

前述の問題は、デベロッパーに安全で堅牢な回復力のあるスマートコントラクトの構築に労力を費やすことを不可欠にしました。 スマートコントラクトのセキュリティは深刻な課題であり、全てのデベロッパーが学ぶべきことです。 このガイドでは、イーサリアムデベロッパーのためのセキュリティの考慮事項について説明します。さらに、スマートコントラクトのセキュリティ向上に役立つリソースもご紹介します。

前提知識

セキュリティに取り組む前に、スマートコントラクト開発の基礎に精通しておいてください。

安全なイーサリアムスマートコントラクトを構築するためのガイドライン

1. 適切なアクセス制御設計

スマートコントラクトでは、publicまたはexternalが記述されている関数は、どの外部所有アカウント (EOA) もしくはコントラクトアカウントからでも呼び出すことができます。 他のコントラクトが自分のコントラクトとやり取りできるようにするには、関数にpublicの可視性を指定する必要があります。 一方でprivateと記述されている関数は、外部アカウントで呼び出すことはできず、スマートコントラクト内の関数でしか呼び出すことはできません。 全てのネットワーク参加者にコントラクト関数へのアクセスを許可してしまうと、特に、細心の注意が求められる操作(例: 新しいトークンのミントなど)を誰でも実行できる場合に、さまざまな問題を引き起こす可能性があります。

スマートコントラクト関数の無許可での使用を防ぐために、安全なアクセス制御の実装が必要です。 アクセス制御メカニズムは、スマートコントラクトの特定の関数を、コントラクトを管理する責任を負うアカウントなどの承認されたエンティティだけが使用できるように制限します。 Ownableパターンロールベースアクセス制御は、スマートコントラクトでアクセス制御を実装する際に有用なパターンです

Ownableパターン

Ownableパターンでは、コントラクト作成プロセスでアドレスがコントラクトの「オーナー(所有者)」として設定されます。 保護される関数には、OnlyOwner修飾子が指定されます。これにより、関数を実行する前にコントラクトが呼び出し元のアドレスのアイデンティティを認証するようになります。 保護された関数に対するコントラクトの所有者以外のアドレスからの呼び出しは、常に取り消されます。これにより、不正なアクセスが防止されます。

ロールベースアクセス制御

スマートコントラクトに単一のアドレスをOwnerとして登録すると、集中化のリスクをもたらし、単一障害点となります。 所有者のアカウントキーが侵害された場合、攻撃者はその所有者のコントラクトを攻撃できます。 これが、複数の管理アカウントを使用するロールベースアクセス制御パターンを採用した方が良い理由です。

ロールベースアクセス制御では、細心の注意が求められる関数へのアクセスは、信頼できる一連の参加者の間で分散されます。 例えば、あるアカウントがトークンをミントする役割を担い、別のアカウントがアップグレードもしくはコントラクトの一時停止を実行する役割を担います。 このようにアクセス制御を分散化することで、単一障害点を取り除き、ユーザーの信頼の前提を減らします。

マルチシグウォレットの使用

安全なアクセス制御を実装するもう一つのアプローチとして、マルチシグ(複数署名)アカウントでコントラクトを管理することもできます。 通常のEOAと異なり、マルチシグアカウントは複数のエンティティに所有されます。また、トランザクションの実行には最低数のアカウントの署名 (例: 5つのうち3つの署名など) が必要です。

アクセス制御でマルチシグ (複数署名) を使用すると、追加のセキュリティレイヤーを導入できます。なぜなら、目的のコントラクトを動作させるには複数の当事者からの同意が必要になるためです。 これはOwnableパターンを使う必要がある場合に特に有用です。これにより、攻撃者や不正な内部関係者が、細心の注意が求められるコントラクト関数を悪意のある目的で操作することをより困難にするためです。

2. コントラクト操作の保護にrequire()、assert()、revert() ステートメントを使用

前述のように、スマートコントラクトをいったんブロックチェーンにデプロイすると、誰でもその中のpublic関数を呼び出すことができます。 外部アカウントがどのようにコントラクトとやり取りするかを事前に知ることはできないため、デプロイする前に、問題のある操作に対して内部的な防御策を講じることが理想的です。 実行する際、特定の要件を満たさない場合は、require()assert()、およびrevert()ステートメントを使用して例外をトリガーし、状態変更を元に戻すことにより、スマートコントラクトで正しい動作を強制できます。

require(): requireは、関数の開始時に定義され、呼び出された関数が実行される前に、事前に定義された条件を確実に満たすようにします。 requireステートメントは、ユーザーの入力の検証、状態変数のチェック、または関数を実行する前に呼び出し元のアカウントのアイデンティティを認証するために使用することができます。

assert(): assert()は、内部エラーの検出や、コード内の「不変条件」の違反をチェックするために使用されます。 不変条件とは、コントラクトの状態に関する論理アサーションであり、すべての関数の実行に対して真 (true) となるべきものです。 不変条件の例としては、トークンコントラクトの最大供給量や残高があげられます。 assert() を使用することで、コントラクトが脆弱な状態にならないようにします。もしそうなった場合は、状態変数へのすべての変更がロールバックされます。

revert(): revert() if-else文の中で使用することができ、必要な条件を満たさない場合に例外を発生させます。 以下のサンプルコントラクトでは、revert()によって関数の実行を保護しています。

1pragma solidity ^0.8.4;
2
3contract VendingMachine {
4 address owner;
5 error Unauthorized();
6 function buy(uint amount) public payable {
7 if (amount > msg.value / 2 ether)
8 revert("Not enough Ether provided.");
9 // Perform the purchase.
10 }
11 function withdraw() public {
12 if (msg.sender != owner)
13 revert Unauthorized();
14
15 payable(msg.sender).transfer(address(this).balance);
16 }
17}
すべて表示

3. スマートコントラクトのテストとコードの正確性の検証

イーサリアム仮想マシンで実行されるコードの不変性は、スマートコントラクトの開発段階において、より高いレベルの品質評価が要求されることを意味しています。 コントラクトを広範にテストし、予期しない結果を観察することでセキュリティは大幅に向上し、長期的にはユーザーを保護することができます。

通常の方法としては、小さな単体テストを作成します。これには、コントラクトがユーザーから受け取ることが予想されるモックデータを使います。 単体テストは、特定の関数の機能をテストしたり、スマートコントラクトが期待通りに動作することを確認したりするのに適しています。

残念ながら、単体テストを単独で行った場合は、スマートコントラクトのセキュリティを向上させるのに最小限の効果しかありません。 単体テストは、関数がモックデータに対して正しく実行されていることを証明するかもしれませんが、作成されたテストに対してのみ有効であるにすぎません。 これは、見落としたエッジケースや脆弱性の検出を難しくするので、スマートコントラクトの安全性を損なう可能性があります。

より良い方法としては、単体テストと静的・動的解析を用いて実施するプロパティベースのテストを組み合わせることです。 静的解析は、制御フローグラフ(opens in a new tab)抽象構文木(opens in a new tab)といった低レベルな表現を頼りに到達可能なプログラムの状態や実行経路を解析します。 一方、スマートコントラクトファジング(opens in a new tab)などの動的解析手法は、ランダムな入力値でコントラクトコードを実行し、セキュリティプロパティに違反する操作を検出します。

形式検証は、スマートコントラクトのセキュリティプロパティを検証するためのもう一つの手法です。 通常のテストとは異なり、形式検証はスマートコントラクトにエラーがないことを決定的に証明することができます。 これは、望ましいセキュリティプロパティをとらえた形式仕様記述を作成し、コントラクトの形式的モデルがこの仕様に準拠していることを証明することで実現されます。

4. 第三者コードレビューの依頼

コントラクトのテスト後、セキュリティ上の問題がないか、他者にソースコードの確認を依頼する方法もあります。 テストによってスマートコントラクトのすべての欠陥を発見できるわけではありませんが、第三者によるレビューを受けることで、脆弱性を発見できる可能性が高まります。

監査

スマートコントラクトの監査を委託することは、第三者コードレビューを実施する一つの方法です。 監査人は、スマートコントラクトが安全で、品質不良や設計ミスがないようにする重要な役割を担っています。

それでも、監査を特効薬のように受け止めるのは避けるべきです。 スマートコントラクト監査は、すべてのバグを発見できるわけではなく、主に追加のレビューを提供するためのものです。これは、初回の開発とテストでデベロッパーが見逃した問題を検出するのに役立ちます。 スマートコントラクト監査のメリットを最大限に活かすには、コードを適切に文書化し、インラインコメントを追加するなどの監査人と協業するための最善の方法(opens in a new tab)も実践する必要があります。

バグ報奨金

バグ報奨金プログラムを設けることは、外部コードレビューを実施するためのもう一つのアプローチです。 バク報奨金とは、アプリケーション内の脆弱性を発見した個人 (通常はホワイトハットハッカー) に与えられる金銭的な報酬です。

バグ報奨金プログラムが適切に機能すれば、ハッカーコミュニティのメンバーは重大な欠陥がないかコードを検査することでインセンティブを得ることができます。 実例としては、「無限貨幣バグ」があります。このバグにより、イーサリアム上で動作しているオプティミズム(opens in a new tab)というレイヤー2プロトコルで、攻撃者が無限にイーサ(Ether)を発行できてしまうというものでした。 幸運なことに、ホワイトハットハッカーはその欠陥(opens in a new tab)を発見しチームに通知したため、その過程で多額の報酬を得ました(opens in a new tab)

バグ報奨金プログラムの報酬額を、危機にさらされている資金の額に比例して設定すると、有効な戦略となります。 「バグ報奨金スケール(opens in a new tab)」と呼ばれるこのアプローチは、脆弱性を悪用するのではなく、責任をもって開示するように、個人に金銭的なインセンティブを与えるものです。

5. スマートコントラクト開発の最善の方法への準拠

監査やバグ報奨金の制度があるからといって、高品質なコードを書くという責任がなくなるわけではありません。 優れたスマートコントラクトセキュリティは、次の適切な設計と開発プロセスに従うことから始まります。

6. 堅牢な災害復旧計画の実装

安全なアクセス制御の設計、関数修飾子の実装、その他の提案等は、スマートコントラクトのセキュリティを向上させる可能性はありますが、悪意のある攻撃が行われる可能性を完全に排除することはできません。 安全なスマートコントラクトを構築するには、「失敗に備える」ことと攻撃に効果的に対応するための予備計画を持つことが必要になります。 適切な災害復旧計画には、次の構成要素のうち一部またはすべてが組み込まれます。

コントラクトのアップグレード

イーサリアムスマートコントラクトは、デフォルトではイミュータブル (不変) ですが、アップグレードパターンを用いることで可変性をある程度獲得することが可能です。 コントラクトのアップグレードは、重大な欠陥によって古いコントラクトが使用できなくなり、新しいロジックをデプロイすることが最も現実的な選択肢となる場合に必要になります。

コントラクトのアップグレードのメカニズムは様々ですが、「プロキシパターン」はスマートコントラクトのアップグレードでより一般的なアプローチの一つです。 プロキシパターン(opens in a new tab)は、アプリケーションを「状態」と「ロジック」の2つのコントラクトに分割します。 最初のコントラクト (「プロキシコントラクト」と呼ばれる) は、状態変数 (例: ユーザーの残高など) を格納します。一方、2つ目のコントラクトは (「ロジックコントラクト」と呼ばれる) は、コントラクトの関数を実行するためのコードを保持します。

アカウントは、プロキシコントラクトとやり取りを行います。プロキシコントラクトは、すべての関数の呼び出しを低レベル呼び出しであるdelegatecall()(opens in a new tab)を使ってロジックコントラクトへディスパッチします。 通常のメッセージ呼び出しとは異なり、delegatecall()は、 ロジックコントラクトのアドレスで実行されるコードが、呼び出し元コントラクトのコンテキスト内で実行されるようにします。 つまり、ロジックコントラクトは、ロジックのストレージではなく、常にプロキシのストレージに書き込みを行い、元のmsg.sendermsg.valueの値は保持されるということです。

ロジックコントラクトに呼び出しを委任するには、プロキシコントラクトのストレージにロジックコントラクトのアドレスを格納する必要があります。 したがって、コントラクトのロジックをアップグレードするには、別のロジックコントラクトをデプロイし、プロキシコントラクトに新しいロジックコントラクトのアドレスを格納するだけでよいのです。 プロキシコントラクトに対するその後の呼び出しは、自動的に新しいロジックコントラクトにルーティングされるため、実際にコードを修正することなく、コントラクトを「アップグレード」したことになります。

コントラクトのアップグレードの詳細

緊急停止

前述のように、広範な監査とテストでは、スマートコントラクトのすべてのバグを発見することはできません。 デプロイ後にコード内に脆弱性が見つかっても、コントラクトアドレスで実行されるコードを変更することはできないため、パッチを適用することは不可能です。 また、アップグレードメカニズム (例: プロキシパターンなど) は、実装に時間がかかる場合があり (異なる関係者の承認が必要な場合が多いため) 、攻撃者に被害を拡大させる時間を与えるだけです。

最終手段は、コントラクト内の脆弱な関数の呼び出しをブロックする「緊急停止」関数を実装することです。 通常、緊急停止は以下のコンポーネントで構成されています。

  1. スマートコントラクトが停止状態かどうかを示す、グローバルなブール型変数。 この変数は、コントラクトを設定するときにfalseに設定されますが、コントラクトが停止するとtrueに戻ります。

  2. 実行時に上記ブール型変数を参照する関数。 これらの関数には、スマートコントラクトが停止していない場合はアクセスでき、緊急停止機能がトリガーされるとアクセスできなくなります。

  3. 上記ブール型変数をtrueに設定する緊急停止関数へのアクセス権を持つエンティティ。 悪意のある行為を防ぐために、この関数への呼び出しを信頼できるアドレス (例: コントラクトの所有者など) に制限できます。

コントラクトが緊急停止を作動させると、特定の関数は呼び出せなくなります。 これは、グローバル変数を参照するmodifierを使って、選択対象の関数をラップすることで実現されます。 下記は、コントラクトへのこのパターンの実装を記述した(opens in a new tab)です。

1// このコードは、専門的な監査を受けておらず、安全性や正確性を約束するものではありません。 自己責任で利用してくだささい。
2
3contract EmergencyStop {
4
5 bool isStopped = false;
6
7 modifier stoppedInEmergency {
8 require(!isStopped);
9 _;
10 }
11
12 modifier onlyWhenStopped {
13 require(isStopped);
14 _;
15 }
16
17 modifier onlyAuthorized {
18 // Check for authorization of msg.sender here
19 _;
20 }
21
22 function stopContract() public onlyAuthorized {
23 isStopped = true;
24 }
25
26 function resumeContract() public onlyAuthorized {
27 isStopped = false;
28 }
29
30 function deposit() public payable stoppedInEmergency {
31 // Deposit logic happening here
32 }
33
34 function emergencyWithdraw() public onlyWhenStopped {
35 // Emergency withdraw happening here
36 }
37}
すべて表示
コピー

この例では、緊急停止の基本機能を示しています。

  • isStoppedはブール値で、最初は評価がfalseになっており、コントラクトが緊急モードに入ると、評価がtrueになります。

  • 関数修飾子 (modifier)である、onlyWhenStoppedおよび stoppedInEmergencyは、isStopped変数をチェックします。 stoppedInEmergencyは、コントラクトが脆弱な場合にアクセスできないようにする必要がある関数の制御に使用されます(例: deposit()など) 。 これらの関数への呼び出しは取り消されます。

onlyWhenStoppedは、緊急時に呼び出し可能でなければならない関数に使用されます(例: emergencyWithdraw()) 。 このような関数は事態の解決に役立つため、「制限されている関数」のリストから除外されます。

緊急停止機能を使用すると、スマートコントラクトの深刻な脆弱性に対処する際に効果的な応急処置を施すことができます。 しかし、デベロッパーが利己的な理由で起動しないように、ユーザーがデベロッパーを信頼する必要性が高まります。 これに対し、オンチェーン投票メカニズムを採用したり、タイムロック、マルチシグウォレットからの承認など、さまざまな方法で緊急停止を分散管理することが解決策として考えられます。

イベント監視

イベント(opens in a new tab)で、スマートコントラクト関数への呼び出しを追跡し、状態変数の変更を監視できます。 ある当事者が、セーフティクリティカルな行為(例: 資金の引き出しなど)を行うたびに、イベントを発行するようにスマートコントラクトをプログラムするのが理想的です。

イベントのログを取り、それらをオフチェーンで監視することは、コントラクトの稼働状況の内側を明らかにし、悪意のある行為の早期発見に役立ちます。 これにより、チームはハッキングに迅速に対応し、機能の一時停止やアップグレードの実施など、ユーザーへの影響を軽減するための対策を講じることができます。

また、誰かがコントラクトとやり取りするたびにアラートを自動的に転送する、既製の監視ツールを選択することもできます。 これらのツールを使用すると、トランザクション量、関数呼び出しの頻度、関連する特定の関数など、さまざまなトリガーに基づいてカスタムアラートを作成できます。 具体例としては、単一のトランザクションで引き出された金額が、特定のしきい値を超えたときに発行されるアラートをプログラムできます。

7. 安全なガバナンスシステムの設計

コアスマートコントラクトの制御をコミュニティメンバーに委任することで、アプリケーションを分散化することができます。 この場合、スマートコントラクトシステムにガバナンスモジュールを組み込みます。これは、コミュニティメンバーが、オンチェーンのガバナンスシステムを介して管理操作を承認できるようにするメカニズムです。 例えば、プロキシコントラクトを新しい実装へアップグレードするという提案について、トークン保有者による投票を行うことができます。

分散型ガバナンスは、特にデベロッパーとエンドユーザーの利害が一致することもあり、有益なものになり得ます。 それでもなお、スマートコントラクトのガバナンスメカニズムは、誤って実装された場合に新しいリスクをもたらすことがあります。 起こり得るシナリオは、攻撃者がフラッシュローンを利用して膨大な投票力(保有トークン数で測定)を獲得し、悪意のある提案を押し通すというものです。

オンチェーンガバナンスに関連する問題を防ぐ方法の一つとして、タイムロックの使用(opens in a new tab)が挙げられます。 タイムロックは、特定の時間が経過するまでスマートコントラクトが特定のアクションを実行できないようにするものです。 その他の戦略としては、各トークンがロックされている期間に応じて「投票の重み」を割り当てることや、現在のブロックの代わりに過去の期間 (例: 2~3ブロック前) でアドレスの投票力を測定することなどがあります。 どちらの方法も、オンチェーンの投票を思い通りに動かす投票力を短期間で獲得する可能性を減らすことができます。

詳細は、安全なガバナンスシステムの設計(opens in a new tab)DAOにおけるさまざまな投票メカニズム(opens in a new tab)をご覧ください。

8. コードの複雑さの最小化

従来のソフトウェアデベロッパーは、ソフトウェア設計に不必要な複雑さを持ち込まないようにするというKISS (Keep It Simple, Stupid) 原則に慣れ親しんでいます。 これは、「複雑なシステムには複雑な障害が発生する」、さらに複雑さによりコストのかかるエラーが発生しやすいという長年の考え方に従ったものです。

スマートコントラクトが高額の価値を制御する可能性があることを考えると、シンプルさを保ってスマートコントラクトを記述することが特に重要になります。 スマートコントラクトをシンプルに記述するコツは、可能な限りOpenZeppelinコントラクト(opens in a new tab)のような既存のライブラリを再利用することです。 これらのライブラリは、デベロッパーによって広範な監査とテストが行われているため、新しい機能をゼロから書くことでバグを発生させる可能性を減らすことができます。

別の一般的なアドバイスとしては、小さな関数を記述すること、さらにビジネスロジックを複数のコントラクトに分割してコントラクトをモジュラー型にすることがあります。 よりシンプルなコードを書くことで、スマートコントラクトへの攻撃面を減らすだけでなく、システム全体の正確性を推論しやすくなり、設計エラーの可能性を早期に検出できるようになります。

9. 一般的なスマートコントラクトの脆弱性からの保護

再入可能 (リエントランシー)

EVMは同時実行を許可していません。つまり、メッセージ呼び出しに関わる2つのコントラクトは同時に実行できません。 外部呼び出しは、呼び出しが戻るまで呼び出し元のコントラクトの実行とメモリを一時停止させます。その時点で外部呼び出しの実行が正常に進みます。 このプロセスは、別のコントラクトへの制御フロー(opens in a new tab)の移動として形式的に記述することが可能です。

ほとんど場合問題は発生しませんが、信頼できないコントラクトに制御フローを移した場合には、再入可能(リエントランシー)などの問題を引き起こす可能性があります。 元の関数の呼び出しが完了する前に、悪意のあるコントラクトが再び脆弱なコントラクトを呼び出す場合に、再入可能(リエントランシー)攻撃が発生します。 例とともに、このタイプの攻撃を詳しく説明します。

誰でもイーサ (Ether) を入出金できるシンプルなスマートコントラクト (「Victim」) を考えてみましょう。

1// This contract is vulnerable. Do not use in production
2
3contract Victim {
4 mapping (address => uint256) public balances;
5
6 function deposit() external payable {
7 balances[msg.sender] += msg.value;
8 }
9
10 function withdraw() external {
11 uint256 amount = balances[msg.sender];
12 (bool success, ) = msg.sender.call.value(amount)("");
13 require(success);
14 balances[msg.sender] = 0;
15 }
16}
すべて表示
コピー

このコントラクトは、ユーザーが以前コントラクトに入金したETHを引き出せるように、withdraw()関数を公開しています。 引き出しを処理する際、コントラクトは次の操作を行います。

  1. ユーザーのETH残高を確認します。
  2. 呼び出し元のアドレスへ資金を送金します。
  3. 残高を0にリセットし、ユーザーからの追加の引き出しを防止します。

Victimコントラクトのwithdraw()関数は、「checks-interactions-effects」パターンに従っています。 実行に必要な条件 (つまり、ユーザーのETH残高がプラスになっているか) が満たされているかどうかを確認 (checks) し、呼び出し元のアドレスにETHを送金するという相互作用 (interactions) を行った後、トランザクションの結果 (effects) (つまり、ユーザーの残高を減らす) を適用しているのです。

withdraw()が外部所有口座 (EOA) から呼び出された場合、この関数は期待どおりに実行されます。つまり、msg.sender.call.value()は呼び出し元にETHを送金します。 しかし、msg.senderwithdraw()を呼び出すスマートコントラクトアカウントの場合、msg.sender.call.value()を使用して資金を送金すると、そのアドレスに保存されているコードの実行もトリガーすることになります。

以下がそのコントラクトアドレスにデプロイされているコードだと想像してみてください。

1 contract Attacker {
2 function beginAttack() external payable {
3 Victim(victim_address).deposit.value(1 ether)();
4 Victim(victim_address).withdraw();
5 }
6
7 function() external payable {
8 if (gasleft() > 40000) {
9 Victim(victim_address).withdraw();
10 }
11 }
12}
すべて表示
コピー

このコントラクトは以下の3つのことを行うように設計されています。

  1. 別のアカウント (攻撃者のEOAの可能性あり) からの入金を受け入れます。
  2. Victimコントラクトへ1 ETHを入金します。
  3. スマートコントラクトに格納された1 ETHを引き出します。

Attackerには、入力となるmsg.sender.call.valueからの残りのガスが4万以上ならVictim内のwithdraw()を再度呼び出すもう一つの関数があることを除き問題はありません。 これにより、AttackerVictimに再入可能になり、最初のwithdrawの呼び出しが完了する前に、多くの資金を引き出すことができます。 次のようなサイクルになります。

1- 攻撃者のEOAが、1 ETHで「Attacker.beginAttack()」関数を呼び出します。
2- 「Attacker.beginAttack()」関数で、1 ETHを「Victim」へ入金します。
3- 「Attacker」が、「Victim」の「withdraw()」関数を呼び出します。
4- 「Victim」が、「Attacker」の残高を確認します(1 ETH)
5- 「Victim」が、1 ETHを「Attacker」へ送金します(これが、デフォルトの関数をトリガーします)
6- 「Attacker」は、「Victim.withdraw()」関数を再度呼び出します(「Victim」は、最初の引き出しから「Attacker」の残高を減らしていないことに注意してください)
7- 「Victim」は、「Attacker」の残高を確認します(最初の呼び出しの結果が適用されていないため、まだ1 ETHです)
8- 「Victim」は、1 ETH を「Attacker」へ送金します(これが、デフォルトの関数をトリガーし、「Attacker」が「withdraw」関数に再入できるようにします)
9- このプロセスは、「Attacker」がガスを使い果たすまで繰り返されます。ガスがなくなると、「msg.sender.call.value」はさらなる引き出しをトリガーせずに戻ります。
10- 「Victim」は、最終的に最初のトランザクション(および後続のトランザクション)の結果をステート(状態)に適用するので、「Attacker」の残高は0に設定されます。
すべて表示
コピー

要約すると、関数の実行が完了するまで呼び出し元の残高が0にならないため、その後の呼び出しが成功し、呼び出し元が何度も残高を引き出せるようになります。 この種の攻撃は、2016年のDAOハック(opens in a new tab)で行われたように、スマートコントラクトから資金を流出させるために使用されます。 再入可能(リエントランシー)エクスプロイトの公開リスト(opens in a new tab)が示すように、再入可能攻撃は今日でもスマートコントラクトにとって深刻な問題になっています。

再入可能 (リエントランシー) 攻撃を防ぐ方法

再入可能に対処するアプローチとして、checks-effects-interactionsパターン(opens in a new tab)に従うことが挙げられます。 このパターンは、次のように関数の実行を順序付けるものです。最初に、実行を進める前に必要な確認を行うコードが来ます。次に、コントラクトの状態を操作するコードが来ます。最後に、他のコントラクトやEOAとやり取りをするコードが来ます。

checks-effect-interactionパターンは、以下に示しているVictimコントラクトの改訂版で使用しています。

1contract NoLongerAVictim {
2 function withdraw() external {
3 uint256 amount = balances[msg.sender];
4 balances[msg.sender] = 0;
5 (bool success, ) = msg.sender.call.value(amount)("");
6 require(success);
7 }
8}
コピー

このコントラクトは、ユーザーの残高を確認 (check) し、withdraw()関数の結果 (effects) を (ユーザーの残高を0にすることで) 適用し、相互作用 (interaction) (ユーザーのアドレスにETHを送金) の実行へと進みます。 これにより、外部呼び出しの前に、コントラクトがストレージを確実にアップデートするようになり、最初の攻撃を可能にする再入可能の条件が排除されます。 Attackerコントラクトは依然として、NoLongerAVictimを再び呼び出すことができますが、balances[msg.sender]が0にセットされているので、さらなる引き出しをしてもエラーがスローされます。

もう一つのオプションは、関数の呼び出しが完了するまで、コントラクトの状態の一部をロックする相互排他ロック (一般的に「ミューテックス」と呼ばれる) を使用することです。 これは、ブール型変数を使って実装されます。関数が実行される前にtrueに設定し、呼び出しが完了した後にfalseに戻します。 以下の例で見られるように、元の呼び出しがまだ処理中であっても、ミューテックスを使うことで再帰的な呼び出しから関数を守ることができます。これにより、効果的に再入可能を防ぐことができます。

1pragma solidity ^0.7.0;
2
3contract MutexPattern {
4 bool locked = false;
5 mapping(address => uint256) public balances;
6
7 modifier noReentrancy() {
8 require(!locked, "Blocked from reentrancy.");
9 locked = true;
10 _;
11 locked = false;
12 }
13 // This function is protected by a mutex, so reentrant calls from within `msg.sender.call` cannot call `withdraw` again.
14 // The `return` statement evaluates to `true` but still evaluates the `locked = false` statement in the modifier
15 function withdraw(uint _amount) public payable noReentrancy returns(bool) {
16 require(balances[msg.sender] >= _amount, "No balance to withdraw.");
17
18 balances[msg.sender] -= _amount;
19 bool (success, ) = msg.sender.call{value: _amount}("");
20 require(success);
21
22 return true;
23 }
24}
すべて表示
コピー

また、アカウントに資金を送る「プッシュ型決済」システムではなく、ユーザーがスマートコントラクトから資金を引き出す必要がある「プル型決済」(opens in a new tab)システムを利用することでも防止可能です。 これにより、不明なアドレスで不注意にコードをトリガーする可能性を取り除けます (特定のDoS攻撃も防げます) 。

整数のアンダーフローとオーバーフロー

算術演算の結果が許容値の範囲外になると、整数のオーバーフローが発生します。これにより、表現可能な最小値に「ロールオーバー」します。 例えば、uint8は、2^8-1=255までの値だけを格納できます。 255を超える値の算術演算の結果はオーバーフローし、uint0にリセットします。これは、車のオドメーターが最大走行距離 (999999) に達すると0にリセットされるのと同じです。

整数のアンダーフローも同様の理由で発生します。それは算術演算の結果が許容値の範囲を下回った場合です。 例えば、uint80をデクリメントしようと試みると、結果は単純に表現可能な最大値 (255) にロールオーバーします。

整数のオーバーフローとアンダーフローのどちらも、コントラクトの状態変数に予期せぬ変更をもたらし、予定外の実行につながる可能性があります。 以下は、攻撃者がスマートコントラクトの算術オーバーフローを悪用して、不正な操作を行う方法を示した例です。

1pragma solidity ^0.7.6;
2
3// このコントラクトはタイムボールトとして動作するように設計されています。
4// ユーザーは、このコントラクトへ入金できますが、最低一週間は引き出しができません。
5// ユーザーは、一週間よりも長い待機期間になるように待ち時間を延長することもできます。
6
7/*
81. TimeLockをデプロイします
92. TimeLockのアドレスでAttackをデプロイします
103. Attack.attackを呼び出し、1 ETHを送金します 即座にETHが引き出せるようになります。
11
12何が起きたのでしょうか?
13AttackがTimeLock.lockTimeのオーバーフローを引き起こしたため、設定された一週間の待機期間より前に引き出しが可能になりました。
14*/
15
16contract TimeLock {
17 mapping(address => uint) public balances;
18 mapping(address => uint) public lockTime;
19
20 function deposit() external payable {
21 balances[msg.sender] += msg.value;
22 lockTime[msg.sender] = block.timestamp + 1 weeks;
23 }
24
25 function increaseLockTime(uint _secondsToIncrease) public {
26 lockTime[msg.sender] += _secondsToIncrease;
27 }
28
29 function withdraw() public {
30 require(balances[msg.sender] > 0, "Insufficient funds");
31 require(block.timestamp > lockTime[msg.sender], "Lock time not expired");
32
33 uint amount = balances[msg.sender];
34 balances[msg.sender] = 0;
35
36 (bool sent, ) = msg.sender.call{value: amount}("");
37 require(sent, "Failed to send Ether");
38 }
39}
40
41contract Attack {
42 TimeLock timeLock;
43
44 constructor(TimeLock _timeLock) {
45 timeLock = TimeLock(_timeLock);
46 }
47
48 fallback() external payable {}
49
50 function attack() public payable {
51 timeLock.deposit{value: msg.value}();
52 /*
53 if t = current lock time then we need to find x such that
54 x + t = 2**256 = 0
55 so x = -t
56 2**256 = type(uint).max + 1
57 so x = type(uint).max + 1 - t
58 */
59 timeLock.increaseLockTime(
60 type(uint).max + 1 - timeLock.lockTime(address(this))
61 );
62 timeLock.withdraw();
63 }
64}
すべて表示
整数のアンダーフローとオーバーフローを防ぐ方法

バージョン0.8.0以降のSolidityコンパイラは、整数のアンダーフローとオーバーフローを引き起こすコードを拒否します。 しかし、それよりも低いバージョンのコンパイラでコンパイルされたコントラクトでは、算術演算を実行する関数でチェックを行うか、アンダーフローとオーバーフローをチェックするライブラリ (例: SafeMath(opens in a new tab)) を使用する必要があります。

オラクル(Oracle)の改ざん

オラクル(Oracles)は、オフチェーン情報を取得し、スマートコントラクトが使用できるようにオンチェーンに送信します。 オラクルを使用すると、資本市場などのオフチェーンシステムと相互運用するスマートコントラクトを設計して、アプリケーションを大幅に拡張できます。

しかし、オラクルに間違いが含まれており、誤った情報をオンチェーンに送信した場合、スマートコントラクトが誤った入力に基づいて実行され、問題が発生する可能性があります。 これが「オラクルの問題」の根拠であり、ブロックチェーンのオラクルからの情報を確実に正確かつ最新で、タイムリーなものにするということが重要になってきます。

関連するセキュリティ上の懸念は、分散型取引所(DEX)などのオンチェーンのオラクルを使用して、資産の現在価格を取得することです。 分散型金融(DeFi)業界のレンディング(貸付)プラットフォームは、多くの場合、これを行ってユーザーの担保の価値を判断し、そのユーザーが借りられる金額を決定します。

DEXの価格は正確であることが多く、これは市場の均衡を取り戻す裁定者によるものです。 しかし、特にオンチェーンオラクルが過去の取引パターンに基づいて資産価格を計算する場合(通常の計算方法)、改ざんされる可能性があります。

例えば、攻撃者は、レンディングコントラクトとやり取りする直前に、フラッシュローンを利用して、資産の現在価格を人為的に吊り上げることができます。 DEXに資産価格を問い合わせると、通常よりも高い価値が返ってくる (攻撃者の大量の「買い注文」が資産の需要を歪めているため) ため、本来よりも多くの借入ができます。 このような「フラッシュローン攻撃」は、DeFiアプリケーション間の価格オラクルへの依存を悪用し、プロトコルに数百万ドルの損失を与えたと言われています。

オラクルの改ざんを防ぐ方法

オラクルの改ざん(opens in a new tab)を回避するための最小要件としては、単一障害点を避けるために複数のソースから情報を照会する分散型オラクルネットワークを使用することです。 ほとんどの場合、分散型オラクルにはオラクルノードに正しい情報を報告するよう促す暗号経済的なインセンティブが組み込まれており、集中型オラクルよりも安全性が高くなっています。

オンチェーンオラクルに資産価格を照会する場合は、時間加重平均価格(TWAP)メカニズムを実装しているものを使用することを検討してください。 TWAPオラクル(opens in a new tab)は、ある資産の価格を2つの異なる時点(修正可能)で照会し、得られた平均値に基づいて現在価格を計算します。 長い期間を選択することで、直前に行われた大量の注文が資産価格に影響を与えることができないため、価格の不正操作からプロトコルを保護します。

デペロッパー向けスマートコントラクト・セキュリティリソース

スマートコントラクト分析とコードの正確性検証ツール

  • テストツールとライブラリ - スマートコントラクトの単体テスト、静的解析、動的解析を行うための業界標準ツールやライブラリのコレクション。

  • 形式検証ツール - スマートコントラクトの機能的な正しさを検証し、不変条件をチェックするためのツール。

  • スマートコントラクト監査サービス - イーサリアム開発プロジェクト向けのスマートコントラクト監査サービスを提供する企業のリスト。

  • バグ報奨金プラットフォーム - バグ報奨金を調整し、スマートコントラクトの重大な脆弱性を責任を持って開示した人に対して報酬を与えるためのプラットフォーム。

  • Fork Checker(opens in a new tab) - フォークされたコントラクトに関する利用可能なすべての情報を確認するための無料のオンラインツール。

  • ABI Encoder(opens in a new tab) - Solidityコントラクトの関数とコンストラクタの引数をエンコードするための無料のオンラインサービス。

スマートコントラクト監視ツール

スマートコントラクトのセキュリティ管理ツール

スマートコントラクト監査サービス

バグ報奨プログラムプラットフォーム

既知のスマートコントラクトの脆弱性とエクスプロイトの公開

スマートコントラクトのセキュリティ学習課題

スマートコントラクトのセキュリティのベストプラクティス

スマートコントラクトのセキュリティに関するチュートリアル

  • 安全なスマートコントラクトコードの記述方法

  • Slitherを使用してスマートコントラクトのバグを見つける方法

  • Manticoreを使用してスマートコントラクトのバグを見つける方法

  • スマートコントラクトのセキュリティガイドライン

  • トークンコントラクトと任意のトークンを安全に統合する方法

  • Cyfrin Updraft - スマートコントラクトセキュリティおよび監査のフルコース(opens in a new tab)

この記事は役に立ちましたか?