📖

Chapter 13: セキュアなコントラクトを書く | Solidity Programming Essentialsを読む

2022/08/06に公開

イーサリアムの知識を整理するために2022年6月発売のSolidity Programming Essentials 2nd Editionを読み進める試みです。この記事ではChapter13「Writing Secure Contracts」を読み進めます。

Solidity Programming Essentials: A guide to building smart contracts and tokens using the widely used Solidity language, 2nd Edition (English Edition)

読書ログは以下のスクラップで逐次更新していきます。
https://zenn.dev/mah/scraps/ea8c79961ae8c8


この章で扱われるトピックは以下の通りです。

  • The importance of security in smart contracts
  • Improvements in Solidity for solving underflow and overflow hacks
  • Solving reentrancy hacks in Solidity
  • Security best practices from an audit and implementation perspective

SafeMathとアンダーフロー/オーバーフロー攻撃

そもそもSolidityの観点でのアンダーフロー、オーバーフローとは何でしょうか。

整数オーバーフローとは変数が許容できる以上の整数値を代入したときに発生する動きです。このような場合、割り当てられた値が設定されるのではなく、データ型がサポートする最小値から巻き戻して計算されます。例えば0〜255までの数値範囲を受け入れられるuint8型の変数に256という数値を代入すると、変数には1という値が代入されます。同様に257を代入すると、2が代入されます。

整数アンダーフローはオーバーフローと似ています。uint8型の変数に-1を代入すると今度は255が代入され、-2を代入すると254が代入されます。

このような問題からスマートコントラクト内で計算を行う前に与えられた値が意図通りの値かをチェックする必要がありましたが、このチェックを楽に実現してくれるライブラリがあります。OpenZeppelinのSafeMathライブラリです。

OpenZeppelinは再利用可能な複数の標準コントラクトを提供してくれるフレームワークです。コントラクトにインポートして利用することができます。SafeMathライブラリはそのようなコントラクトの一つで、加算、減算、乗算、除算などの主要な演算について、整数のオーバーフロー/アンダーフローのチェックを実行してくれます。

ただしSolidityのバージョン0.8以降はEVM自身がバイトコードを生成しながらチェックを行い、エラーを発生させることができるようになりました。そのため、OpenZeppelinのSafeMathを使うことは冗長になる可能性はあります。

以下は意図的にオーバーフロー/アンダーフローを引き起こす例です。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract UnderOverFlowContract {
  uint8 public upperBoundary;
  uint8 public lowerBoundary;

  constructor() {
    upperBoundary = 255;
    lowerBoundary = 0;
  }

  function AddtoUpperBoundary(uint8 value) public view returns (uint8) {
    return upperBoundary + value;
  }

  function ReducefromLowerBoundary(uint8 value) public view returns (uint8) {
    return lowerBoundary - value;
  }
}

バージョン0.8以降のコンパイラでコンパイルしたものをデプロイし、オーバーフロー/アンダーフローを引き起こすような引数を与えるとEVMエラーになります。

リエントランシー攻撃の概要

コントラクトに保存されている資産を保護することは重要です。コントラクトは公開されているため、常に攻撃の危険性に晒されています。ここではポピュラーな攻撃方法であるリエントランシー攻撃について理解を深めていきます。

リエントランシー攻撃はコントラクトが第三者のアドレスに資産を転送する関数を実装している場合に可能となります。ハッカーはコントラクトのユーザーの一人として、悪意のあるコントラクトを記述します。悪意のあるコントラクトは資産の転送を開始する関数を呼び出しますが、そのレスポンスをトラップし、アセットの引き出しのための再帰的コールバックを行います。この再帰はコントラクト内にある限り継続します。

コントラクト開発の原則の一つは、コントラクト間のやりとりを信頼されないものとして扱うことです。他のコントラクトと通信する場合は常に適切な対策を講じる必要があります。

リエントランシー攻撃の具体例(脆弱性を含むEtherPotコントラクトで検証)

ここではセキュアでないコントラクトに対してリエントランシー攻撃が実際にどのように行われるか見ていきます。このセキュアでないコントラクトをEtherPotと名付け、実装してみます。

以下がそのコードです。GetBalance関数はコントラクトの所有者だけが呼び出せることが理想ですが、現在のところ誰でも呼び出せる関数になっています。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract EtherPot {
  mapping(address => uint256) balances;

  constructor() payable {}

  function AddEther() public payable returns (bool) {
    balances[msg.sender] = balances[msg.sender] + msg.value;
    return true;
  }

  function Withdraw() public returns (bool) {
    require(balances[msg.sender] > 0);
    payable(msg.sender).call{ value: 1000000000000000000 }("");
    delete balances[msg.sender];
    return true;
  }

  function GetBalance() public returns (uint256) {
    return address(this).balance;
  }
}

問題の原因となっている関数はWithdraw関数です。この関数は、呼び出し元がコントラクトで利用可能な資金を持っているかチェックし、持っている場合はcall関数を使って呼び出し元に1ETHを転送します。Etherを転送したあと、マッピング内のアドレスに対する残高を削除することで取り除きます。

このコントラクトは呼び出し元が常に外部アカウントであることを想定しています。呼び出し元が他のコントラクトである可能性は考慮されていません。もう一つの欠陥は、より良い代替手段があるにも関わらず、Etherを呼び出し側に転送するために低レベルのcall関数を利用していることです。

それではHackerコントラクトからEtherPotコントラクトを攻撃してみましょう。

Hackerコントラクトによる攻撃

ハッカーは小さなコントラクトを記述し、EtherPotのユーザーの一人になりすますことでEtherPotとインタラクションします。

以下に示すHackerコントラクトはEtherPotの構成を模倣しており、かつ外部アカウントであるように見せかけています。更にreceive関数も実装しています。この関数はコントラクトがEtherを受け取る度に自動的に実行されます。

contract Hacker {
  address payable etherpot;

  constructor(address payable _etherpot) payable {
    etherpot = _etherpot;
  }

  function SendEther() public {
    EtherPot(etherpot).AddEther{ value: 1000000000000000000 }();
  }

  function WithdrawEther() public {
    EtherPot(etherpot).Withdraw();
  }

  function GetBalance() public view returns (uint256) {
    return address(this).balance;
  }

  receive() external payable {
    EtherPot(etherpot).Withdraw();
  }
}

receive関数からEtherPotコントラクトのWithdraw関数を呼び出していることに注目しましょう。このコードがコントラクト間の再帰ループを引き起こします。EtherPotWithdraw関数内でEtherの転送が行われ、それが自動的にHackerコントラクトのreceive関数を呼び出し、そこから更にWithdraw関数が呼ばれる・・・というループを引き起こすのです。この処理はEtherPot内のEtherがなくなるまで続きます。

リエントランシー攻撃の回避方法

リエントランシー攻撃を回避するためには多くのベストプラクティスがあり、更にこのベストプラクティスを採用することによって他の多くの問題を解決することができます。

以下にベストプラクティスを示していきます。

コントラクトアカウントかどうかをチェックする

そもそもコントラクトアカウントと通信する前提にない場合は、外部アカウント以外アクセスできないようにした方が安全です。この方法はChapter: 11 Assembly Programmingで学びました。

Checks-Effects-Interactionsパターンを採用する

Checks-Effects-Interactionsパターンは、コントラクトの状態を変更し、トークンやEtherを他のアカウントに転送する関数内のシーケンスに3つのステージがあることを示します。入力された引数が正しいかどうかの検証は、すべてChecksステージの一部として実行されます。

  • Checksステージではコントラクトの現在の状態を確認することも含まれます。Checksステージではコントラクトの状態と入力される引数の観点から何も問題がないことを確認します。チェックに失敗した場合は実行を停止します。
  • 次にEffectsステージでは、実装されたロジックに基づいてステートを変化させる処理を実行します。この段階ではEtherの転送が行われず、他の第三者のアドレスやアカウントとの外部通信も行われていません。ステート変更時に問題が起こった場合は、ステートをロールバックし実行を停止します。
  • 最後のInteractionsステージで全ての外部通信とEtherの転送が行われます。このパターンにより、ステートの変更が正常終了した場合のみ外部とのインタラクションが発生することが保証されます。そのため、誰かがコントラクトにリエントランシー攻撃を仕掛けようとしても成功することはありません。

tx.originとmsg.senderの両方のアドレスを検証する

tx.originmsg.senderが同じアドレスを参照している場合、コントラクト間の関数呼び出しではなく、外部アカウントにより関数が呼び出されていることが分かります。コントラクトはトランザクションを開始できず、外部アカウントのみがトランザクションを開始できるため、このことが保証されます。

安全な関数を利用する

コントラクトからEtherを転送する方法として、send関数やcall関数よりも、transfer関数を利用するといった安全な方法を選択するべきです。transfer関数は2,300ガスを固定で消費するため、fallback関数やreceive関数はその量のガスを使用して再帰的な呼び出しをすることができません。

コントラクトの停止

セキュアなコーディングを全て実装しても攻撃が起こる可能性は十分にあります。コントラクトは攻撃を受けた場合、haltableパターンを実装することによって全ての操作を停止または凍結することでき、攻撃による損失を最小限に抑えることができます。

呼び出し方向を変更する

アドレスを使用してEtherを転送する代わりに、ターゲットがコントラクトであると判断した後にコントラクトは関数の呼び出しを開始し、呼び出しと共に適切なEtherを送信することができます。

OpenZeppelinを利用する

OpenZeppelinではReentrancyGuardという仕組みが提供されています。

EtherPotの改善例

ここまでの学びを踏まえたEtherPotのWithdraw関数の改善例は以下の通りです。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;

contract EtherPot {
  mapping(address => uint256) balances;

  constructor() payable {}

  function AddEther() public payable returns (bool) {
    balances[msg.sender] = balances[msg.sender] + msg.value;
    return true;
  }

  function Withdraw() public returns (bool) {
    address sender = msg.sender;
    require(tx.origin == msg.sender);
    require(balances[msg.sender] > 0);
    balances[msg.sender] = 0;
    payable(msg.sender).transfer(1000000000000000000);
    return true;
  }

  function GetBalance() public returns (uint256) {
    return address(this).balance;
  }
}

セキュリティベストプラクティス

更に重要なベストプラクティスについてまとめてみます。以下は完全なリストではありませんが、開発時に留意するには十分な数のプラクティスです。より広範なものを確認したい場合はSWC Registryを確認し、チェックのためのツールが必要な場合は https://consensys.net/diligence/tools/ を確認しましょう。

コントラクト内の関数が備えているべきもの

  • 最も制約の多いデータ型で、必要な数のパラメータのみ受け付け、余分なパラメータがあってはなりません。
  • 関数からの戻り値の型は、適切なデータ型に制約されるべきです。
  • 関数の最初のアクションとして引数入力の検証を行いましょう。
  • 他のコントラクトを呼び出す場合はtry-catchブロックを利用して適切に例外処理しましょう。
  • 適切なスコープ、可視性を割り当てを行いましょう。コントラクト内からしか呼び出せない関数はpublicとマークせず、外部リクエストからのみ呼び出せる場合はexternalとマークしましょう。
  • 関数内のあらゆるトランザクション、特にコントラクトからのトークンやEtherの転送にはChecks-Effetcs-Interactionsパターンを採用しましょう。
  • マッピングや配列をループさせるのは避けましょう。コストと時間がかかり、スタックオーバーフロー例外が発生する可能性もあります。
  • ガスの使用量をチェックし、消費を最小限に抑えるようにしましょう。
  • 最小特権の原則を採用しましょう。必要以上の権限を与えるべきではありません。
  • 不要なコードは削除して、攻撃対象領域を減らしましょう。
  • 複数の関数間で繰り返し使用されるコードには修飾子を利用しましょう。
  • Etherを第三者のアドレスに転送する仕組みはよくレビューしましょう。transfer関数はsendよりも優れており、callよりはsendの方が良いです。

コントラクト実装のベストプラクティス

  • 停止可能(haltable)なコントラクトを実装すること。
  • 他のコントラクト上の関数を呼び出したりEtherを転送するためにcallcallcodeなどの低レベル関数を使用しないこと。
  • 必要な場合はrequire(tx.origin == msg.sender)のようなコードで呼び出し元が外部アカウントかどうか検証しましょう。
  • ステートの変化を伴う関数ではイベントを発生させ、イベントから十分な情報を収集できるようにしましょう。

この章の内容は以上です。

Discussion