セキュリティへの配慮

想定通りに動作するソフトウェアを作るのは簡単ですが、想定外の使い方をされないようにチェックするのは非常に困難です。

Solidityでは、スマートコントラクトを使ってトークンや、もっと価値のあるものを扱うことができるので、セキュリティはさらに重要です。 加えて、スマートコントラクトの実行はすべて公開されており、ソースコードも公開されることがあります。

もちろん、セキュリティにどれだけの価値があるかは常に考えなければなりません: スマートコントラクトは、パブリックに公開されていて(つまり悪意のあるアクターにも公開されていて)オープンソースであることがあるWebサービスと比較できます。 そのウェブサービスに食料品のリストを保存するだけであれば、それほど注意を払う必要はないかもしれませんが、そのWebサービスを使って銀行口座を管理する場合には、より注意を払う必要があります。

このセクションでは、いくつかの落とし穴や一般的なセキュリティ上の推奨事項を挙げていきますが、もちろん完全なものではありません。 また、スマートコントラクトのコードにバグがない場合でも、コンパイラやプラットフォーム自体にバグがある可能性があることも覚えておいてください。 コンパイラのセキュリティ関連の既知のバグのリストは 既知のバグリスト に掲載されており、機械でも読めます。 なお、Solidityコンパイラのコードジェネレータを対象とした Bug Bounty Program があります。

いつものように、オープンソースのドキュメントでは、このセクションの拡張にご協力ください(特に、いくつかの例があれば問題ありません)。

注: 以下のリストの他にも、セキュリティに関する推奨事項やベストプラクティスが Guy Landoのリスト および ConsensysのGitHubリポジトリ に掲載されています。

落とし穴

プライベートの情報とランダム性

スマートコントラクト上で利用できるものはすべて公開されており、ローカル変数や private と書かれた状態変数も公開されています。

スマートコントラクトで乱数を使用することは、ブロックビルダーが不正行為をする可能性があるため、困難です。

Reentrancy

コントラクト(A)と別のコントラクト(B)とのインタラクションやEtherの送金は、コントラクト(B)に制御権を渡します。 これにより、このインタラクションが完了する前に、BがAにコールバックすることが可能になります。 例として、以下のコードにはバグが含まれています(これは単なるスニペットであり、完全なコントラクトではありません)。

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

// このコントラクトにはバグが含まれています - 使わないでください
contract Fund {
    /// @dev Etherシェアのマッピング
    mapping(address => uint) shares;
    /// シェアを引き出す
    function withdraw() public {
        if (payable(msg.sender).send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}

この問題は、 send にガス制限があるため、それほど深刻ではありませんが、それでも脆弱性があります。 Etherの送金には常にコードの実行が含まれるため、受信者は withdraw にコールバックするコントラクトになる可能性があります。 これにより、複数回の払い戻しが可能となり、基本的にはコントラクト内のすべてのEtherを回収できます。 特に、以下のコントラクトは、デフォルトで残りのガスをすべて送金する call を使用しているため、攻撃者は複数回返金できます。

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

// このコントラクトにはバグが含まれています - 使わないでください
contract Fund {
    /// @dev Etherシェアのマッピング
    mapping(address => uint) shares;
    /// シェアを引き出す
    function withdraw() public {
        (bool success,) = msg.sender.call{value: shares[msg.sender]}("");
        if (success)
            shares[msg.sender] = 0;
    }
}

Re-entrancyを避けるために、以下のようなChecks-Effects-Interactionsパターンを使用できます。

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

contract Fund {
    /// @dev Etherシェアのマッピング
    mapping(address => uint) shares;
    /// シェアを引き出す
    function withdraw() public {
        uint share = shares[msg.sender];
        shares[msg.sender] = 0;
        payable(msg.sender).transfer(share);
    }
}

Checks-Effects-Interactionsパターンは、コントラクトを通るすべてのコードパスが、コントラクトの状態を変更する前に、供給されたパラメータの必要なチェックをすべて完了することを保証します(Checks)。 その後、コントラクトはステートに変更を加えます(Effects)。 計画されたステートの変更がすべてストレージに書き込まれた後、他のコントラクトの関数を呼び出すことができます(Interactions)。 これは、外部から呼び出された悪意のあるコントラクトが、トランザクションを確定する前に元のコントラクトにコールバックするロジックを使用することで、手当を二重に使ったり、残高を二重に引き出したりできる、 リエントランシー攻撃 を防ぐための一般的なフールプルーフな方法です。

Reentrancyは、Ether送金だけでなく、別のコントラクトでのあらゆる関数呼び出しの影響を受けることに注意してください。 さらに、複数のコントラクトを考慮しなければならない状況もあります。 呼び出されたコントラクトが、依存している別のコントラクトの状態を変更する可能性があります。

ガスリミットとループ

例えば、ストレージの値に依存するループなど、反復回数が固定されていないループは、慎重に使用する必要があります。 ブロックガスリミットにより、トランザクションは一定量のガスしか消費できません。 明示的に、または通常の操作によって、ループの反復回数がブロックガスリミットを超えてしまい、コントラクト全体がある時点で停止してしまうことがあります。 これは、ブロックチェーンからデータを読み取るためだけに実行される view 関数には当てはまらないかもしれません。 それでも、そのような関数はオンチェーン操作の一部として他のコントラクトから呼び出され、それらを引き伸ばすことができます。 このようなケースについては、コントラクトのドキュメントで明示してください。

Etherの送受信

  • コントラクトも「外部アカウント」も、誰かがEtherを送ってくるのを防ぐことは今のところできません。 コントラクトは、通常の送金に反応して拒否できますが、メッセージコールを作成せずにEtherを移動する方法があります。 ひとつはコントラクトのアドレスに単純に「マイニング」する方法で、もうひとつは selfdestruct(x) を使う方法です。

  • コントラクトが(関数が呼ばれずに)Etherを受信すると、 receive Ether または fallback 関数が実行されます。 receive 関数も fallback 関数も持たない場合、Etherは(例外を投げて)拒否されます。 これらの関数が実行されている間、コントラクトは、渡された「gas stipend」(2300ガス)がその時点で利用可能であることにのみ依存できます。 この供給量は、ストレージを変更するのに十分ではありません(将来のハードフォークで供給量が変更される可能性がありますので、これを鵜呑みにしてはいけません)。 コントラクトがこの方法でEtherを受け取ることができるかどうかを確認するには、receive関数とfallback関数のガス要件を確認してください(例えばRemixの「詳細」セクションに記載されています)。

  • addr.call{value: x}("") を使用して、より多くのガスを受信コントラクトに送金する方法があります。 これは基本的に addr.transfer(x) と同じですが、残りのガスをすべて送金し、受信側がより高価なアクションを実行できるようにします(また、自動的にエラーを伝播するのではなく、失敗コードを返します)。 これには、送信側のコントラクトにコールバックすることや、あなたが考えもしなかったような他の状態変化が含まれるかもしれません。 そのため、誠実なユーザーだけでなく、悪意のあるアクターにも大きな柔軟性を与えることができます。

  • weiの量を表す単位は、精度が低いために丸められたものは失われてしまうので、できるだけ正確な単位を使ってください。

  • address.transfer を使ってEtherを送信する場合、注意すべき点があります。

    1. 受信者がコントラクトの場合、そのreceive関数またはfallback関数を実行させ、その結果、送信側のコントラクトをコールバックできます。

    2. コールの深さが102以上になると、Etherの送信に失敗することがあります。

    3. 呼び出し側はコールの深さを完全にコントロールしているため、強制的に送金を失敗させることができます。 この可能性を考慮して send を使用するか、その戻り値を常に確認するようにしてください。 さらに言えば、受取人が代わりにEtherを引き出せるようなパターンでコントラクトを書いてください。

    4. Etherの送信は、受信者のコントラクトの実行に割り当てられた量以上のガスが必要となるため( requireassertrevert を使用して明示的に、または操作が高すぎるため)、「ガス欠」(OOG)となって失敗することもあります。 transfer または send を戻り値のチェックとともに使用すると、受信者が送信側のコントラクトの進行をブロックする手段となる可能性があります。 ここでも、 sendパターンの代わりにwithdrawパターン を使用するのがベストです。

コールスタックの深さ

外部関数の呼び出しは、コールスタックの最大サイズ制限である1024を超えるため、いつでも失敗する可能性があります。 このような状況では、Solidityは例外を投げます。 悪意のあるアクターは、コントラクトと対話する前にコールスタックを強制的に高い値にできるかもしれません。 Tangerine Whistle のハードフォーク以来、 63/64ルール はコールスタックの深さの攻撃を実用的ではないものにしていることに注意してください。 また、コールスタックとエクスプレッションスタックは、どちらも1024のスタックスロットというサイズ制限がありますが、無関係であることに注意してください。

.send() はコールスタックが枯渇した場合に例外を発生させず、 false を返すことに注意してください。 低レベル関数の .call().delegatecall().staticcall() も同じように動作します。

認可されたプロキシ

コントラクトがプロキシとして動作できる場合、つまり、ユーザーが提供したデータで任意のコントラクトを呼び出すことができる場合、ユーザーは基本的にプロキシのコントラクトのアイデンティティを仮定できます。 他の保護手段があったとしても、プロキシが(自分自身のためでさえも)いかなる許可も持たないようにコントラクトシステムを構築することが最善です。 必要であれば、第二のプロキシを使ってそれを達成できます。

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.0;
contract ProxyWithMoreFunctionality {
    PermissionlessProxy proxy;

    function callOther(address addr, bytes memory payload) public
            returns (bool, bytes memory) {
        return proxy.callOther(addr, payload);
    }
    // その他の関数や機能
}

// これは完全なコントラクトであり、他の機能はなく、動作するために特権を必要としません。
contract PermissionlessProxy {
    function callOther(address addr, bytes memory payload) public
            returns (bool, bytes memory) {
        return addr.call(payload);
    }
}

tx.origin

認証に tx.origin を使用しないでください。 以下のようなウォレットコントラクトがあるとします。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
// このコントラクトにはバグが含まれています - 使わないでください
contract TxUserWallet {
    address owner;

    constructor() {
        owner = msg.sender;
    }

    function transferTo(address payable dest, uint amount) public {
        // バグはここにあります。tx.originの代わりにmsg.senderを使用する必要があります。
        require(tx.origin == owner);
        dest.transfer(amount);
    }
}

今度は誰かに騙されて、この攻撃用ウォレットのアドレスにEtherを送ってしまうとしましょう。

// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.7.0 <0.9.0;
interface TxUserWallet {
    function transferTo(address payable dest, uint amount) external;
}

contract TxAttackWallet {
    address payable owner;

    constructor() {
        owner = payable(msg.sender);
    }

    receive() external payable {
        TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
    }
}

もしあなたのウォレットが msg.sender をチェックして承認を得ていたら、所有者のアドレスではなく、攻撃したウォレットのアドレスを得ることになります。 しかし、 tx.origin をチェックすると、トランザクションを開始した元のアドレスが取得され、それがオーナーのアドレスとなります。 攻撃されたウォレットは即座にあなたの資金をすべて使い果たしてしまいます。

2の補数 / アンダーフロー / オーバーフロー

多くのプログラミング言語と同様に、Solidityの整数型は実際には整数ではありません。 値が小さいときは整数に似ていますが、任意に大きな数値を表すことはできません。

以下のコードでは、加算結果が大きすぎて uint8 型に格納できないため、オーバーフローが発生します。

uint8 x = 255;
uint8 y = 1;
return x + y;

Solidityには、これらのオーバーフローを処理する2つのモードがあります。 チェックされたモードとチェックされていないモード、つまり「ラッピング」モードです。

デフォルトのチェックモードでは、オーバーフローを検出し、アサーションの失敗を引き起こします。 unchecked { ... } を使ってこのチェックを無効にすることで、オーバーフローを静かに無視できます。 上記のコードは、 unchecked { ... } でラップすると 0 を返します。

チェックモードであっても、オーバーフローのバグから守られていると思わないでください。 このモードでは、オーバーフローは必ずリバートします。 オーバーフローを回避できない場合、スマートコントラクトが特定の状態で立ち往生してしまう可能性があります。

一般的には、2の補数表現の限界について読んでみてください。 2の補数表現には、符号付きの数字に対するより特別なエッジケースもあります。

require を使って入力の大きさを合理的な範囲に制限し、 SMTチェッカー を使ってオーバーフローの可能性を見つけるようにしましょう。

マッピングのクリア

Solidityの型 mappingマッピング型 参照)は、ストレージのみのキーバリューデータ構造で、ゼロ以外の値が割り当てられたキーを追跡しません。 そのため、書き込まれたキーに関する余分な情報を持たないマッピングのクリーニングは不可能です。 mapping が動的ストレージ配列の基本型として使用されている場合、配列を削除したりポップしたりしても mapping の要素には影響しません。 例えば、動的ストレージ配列のベース型である struct のメンバーフィールドの型として mapping が使用されている場合も同様です。 また、 mapping を含む構造体や配列の代入においても、 mapping は無視されます。

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

contract Map {
    mapping(uint => uint)[] array;

    function allocate(uint newMaps) public {
        for (uint i = 0; i < newMaps; i++)
            array.push();
    }

    function writeMap(uint map, uint key, uint value) public {
        array[map][key] = value;
    }

    function readMap(uint map, uint key) public view returns (uint) {
        return array[map][key];
    }

    function eraseMaps() public {
        delete array;
    }
}

上の例で、次のような一連のコールを考えてみましょう: allocate(10), writeMap(4, 128, 256) 。 この時点で、 readMap(4, 128) を呼び出すと256を返します。 eraseMaps を呼び出すと、状態変数 array の長さはゼロになりますが、その mapping 要素はゼロにできないので、その情報はコントラクトのストレージの中で生き続けます。 array を削除した後、 allocate(5) を呼び出すと、再び array[4] にアクセスできるようになり、 readMap(4, 128) を呼び出すと、 writeMap を再度呼び出さなくても256を返します。

mapping の情報を削除する必要がある場合は、 iterable mapping と同様のライブラリを使用することを検討し、適切な mapping でキーをトラバースしてその値を削除できます。

マイナーな内容

  • 32バイトを完全に占有しない型には、「ダーティな高次ビット」が含まれている可能性があります。 これは msg.data にアクセスする場合に特に重要で、不正改造の危険性があります: 関数 f(uint8 x) を生のバイト引数 0xff000001 で呼び出すトランザクションと、 0x00000001 で呼び出すトランザクションを作ることができます。 両方ともコントラクトに供給され、 x に関しては両方とも 1 という数字に見えますが、 msg.data は異なるものになりますので、何かに keccak256(msg.data) を使うと、異なる結果になります。

推奨事項

警告を真摯に受け止める

コンパイラが何かを警告したら、それを変更すべきです。 その警告がセキュリティに影響するとは思わなくても、その下に別の問題が隠れているかもしれません。 私たちが発するコンパイラの警告は、コードを少し変更するだけで黙らせることができます。

最近導入されたすべての警告について通知を受けるには、常に最新バージョンのコンパイラを使用してください。

コンパイラが発行する info 型のメッセージは危険なものではなく、ユーザにとって有用であるとコンパイラが考える追加の提案やオプション情報を表しています。

Etherの量を制限する

スマートコントラクトに格納できるEther(または他のトークン)の量を制限します。 ソースコードやコンパイラ、プラットフォームにバグがあると、これらの資金が失われる可能性があります。 損失を制限したい場合は、Etherの量を制限してください。

小さくモジュール化する

コントラクトは小さく、理解しやすいものにしましょう。 関係のない機能は他のコントラクトやライブラリにまとめてください。 もちろん、ソースコードの品質に関する一般的な推奨事項も適用されます。 ローカル変数の量や関数の長さなどを制限してください。 また、あなたの意図が何であるか、それがコードが行うことと異なるかどうかを他の人が理解できるように、関数を文書化してください。

Checks-Effects-Interactionsパターンを使う

ほとんどの関数は最初にいくつかのチェックを行い、それらは最初に行うべきです。 例えば、誰が関数を呼び出したか、引数は範囲内か、十分なイーサを送ったか、その人はトークンを持っているか、などです。

2番目のステップとして、すべてのチェックがパスした場合、現在のコントラクトの状態変数への効果が作られるべきです。 他のコントラクトとのやりとりは、どの関数でも最後のステップにすべきです。

初期のコントラクトでは、いくつかの効果を遅らせ、外部の関数呼び出しが非エラー状態で戻ってくるのを待っていました。 これは、上で説明したReentrancyの問題のため、しばしば重大な誤りとなります。

なお、既知のコントラクトを呼び出すと、未知のコントラクトを呼び出す可能性もあるので、常にこのパターンを適用するのが良いでしょう。

フェイルセーフモードを搭載する

システムを完全に非中央集権化することで、仲介者を排除できますが、特に新しいコードには、何らかのフェイルセーフメカニズムを組み込むことが良いかもしれません。

スマートコントラクトの中に、「Etherが漏れていないか」「トークンの合計がコントラクトの残高と同じか」などの自己チェックを行う関数を追加できます。 そのためには、あまり多くのガスを使うことはできないので、オフチェーンの計算による助けが必要になるかもしれないことを覚えておいてください。

セルフチェックに失敗すると、コントラクトは自動的にある種の「フェイルセーフ」モードに切り替わります。 例えば、ほとんどの機能を無効にしたり、固定された信頼できる第三者にコントロールを委ねたり、あるいは単に「Etherを返してください」というコントラクトに変更したりします。

ピアレビューを依頼する

多くの人がコードを検証すればするほど、多くの問題が見つかります。 また、人にコードを見てもらうことで、コードがわかりやすいかどうかのクロスチェックにもなり、これは優れたスマートコントラクトにとって非常に重要な基準です。