Security Considerations

想定通りに動くソフトウェアを作るのは割と簡単である一方で、想定していない 方法で誰かがそのソフトウェアを使うのをチェックするのは非常に難しいです。

Solidityでは、トークンやもしかしたらもっと価値のあるものを扱うためにスマートコントラクトを使うので、これは非常に重要です。さらに、スマートコントラクトの全ての実行は公に行われ、加えてソースコードは多くの場合誰でも見ることができます。

もちろん常にどの程度危険があるのか考えなくてはいけません。 スマートコントラクトと公にオープンなWebサービス(悪意のあるものも)やオープンソースを比較して下さい。 もし食料品の買い物リストをwebサービスに保存するなら、さほど気にする必要はないと思いますが、銀行口座を管理するなら、もっと気にする必要があります。

このセクションではいくつかの隠れた危険や、一般的なセキュリティに関する勧告をリストアップしますが、もちろん完璧にはできません。また、もしコントラクトコードにバグがなかったとしても、コンパイラやプラットフォーム自体にバグがあるかもしれません。パブリックに知られているコンパイラのセキュリティ関連のバグは list of known bugs で確認でき、これはコンピュータでも読み込めます。Solidityコンパイラのコード生成プログラムもカバーするバグの報奨制度もあります。

オープンソースドキュメントでは毎度の事ながら、このセクションをより良くするのにご助力下さい(特に、用例は助かります)。

Pitfalls

Private Information and Randomness

スマートコントラクトで使うもの全てはパブリックに見ることができます。たとえそれがローカル変数であったり、private とついた状態変数だったとしてもです。

スマートコントラクトでマイナーにズルをさせない様に乱数を使うのは結構難しいです。

Re-Entrancy

あるコントラクト(A)から別のコントラクト(B)に対する相互作用とEtherの送金はコントラクト(B)にコントロールを渡してしまいます。このおかげで、相互作用が完了する前にBがAに戻れてしまいます。下記の例ではバグが入っています(コントラクト全体ではなく一部です)。

pragma solidity >=0.4.0 <0.6.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        if (msg.sender.send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}

send の一部としてガスが制限されているので大きな問題にはなりませんが、脆弱性を晒しています。Etherの送金は常にコードの実行を含んでいますので、受療者は withdraw をコールバックするコントラクトにもなり得ます。これは何度もリファンドすることを可能にし、基本的にはコントラクト中の全てのEtherを引き出せます。特に、下記のコントラクトでは、デフォルトで残っているガスを全て転送する call が使われているので、攻撃者が複数回リファンドができます。

pragma solidity >=0.4.0 <0.6.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract Fund {
    /// Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        (bool success,) = msg.sender.call.value(shares[msg.sender])("");
        if (success)
            shares[msg.sender] = 0;
    }
}

re-entrancyを避けるために、下記で説明する様にChecks-Effects-Interactionsパターンを使うことができます。

pragma solidity >=0.4.11 <0.6.0;

contract Fund {
    /// Mapping of ether shares of the contract.
    mapping(address => uint) shares;
    /// Withdraw your share.
    function withdraw() public {
        uint share = shares[msg.sender];
        shares[msg.sender] = 0;
        msg.sender.transfer(share);
    }
}

re-entrancyはEtherの送金だけでなく、他のコントラクト上でのファンクションコールでも起こり得ることを覚えておいて下さい。さらに、マルチコントラクトも考慮に入れる必要があります。呼び出されたコントラクトはあなたが使っている他のコントラクトのステートを変更する可能性があります。

Gas Limit and Loops

例えば、ストレージの値によって決まるループの様な繰り返しの回数が決まっていないループを使うときには気をつける必要があります。 ブロックガスリミットのため、トランザクションはある量のガスしか消費できません。明示化している、もしくは通常の演算のため、ループの繰り返し回数はブロックガスリミットを超えてしまい、あるところで不備がないコントラクトでも止まってしまいます。 これはブロックチェーンからデータを読み込むだけの view には適用されないかもしれませんが、オンチェーンの演算の一部として他のコントラクトから呼ばれた場合、その演算を止めるかもしれません。あなたのコントラクトのドキュメンテーションにこの様なケースについて明記して下さい。

Sending and Receiving Ether

  • 現状誰かがコントラクトもしくは"external accounts"にEtherを送るのを止めることはできません。 コントラクトは通常のtransferに対しては反応したり、拒否したりできますが、メッセージコールを生成しないでEtherを移動させる方法があります。その1つはシンプルにコントラクトアドレスに"マイニング"する方法で、2つ目は selfdestruct(x) を使うことです。
  • コントラクトがEtherを受け取ったとき(ファンクションを呼び出さないで)、フォールバックファンクションが実行されます。 もしフォールバックファンクションがなかった場合、Etherの送金は拒否されます(例外が投げられます)。 フォールバックファンクションの実行中、コントラクトはその時渡された利用可能な"gas stipend"にのみ頼っています(2300ガス)。このstipendはストレージを変更するには足りません(これが普通だとは考えないで下さい。このstipendは将来のハードフォークで変わる可能性があります)。 この方法でコントラクトが確実にEtherを受け取れる様にするために、フォールバックファンクションのガス要求を確認して下さい(具体的にはRemixの"details"セクションで)。
  • addr.call.value(x)("") を使ってコントラクトにもっとガスを送る方法があります。 これは本質的には addr.transfer(x) と同じで、それは残っている全てのガスを送り、受領者がもっとガスがかかる演算を行わすことができます(自動でエラーを出す代わりにフェイラーコードを返します)。これは送信したコントラクトへのコールバックや多分想定していなかった他のステートの変更を含みます。そのため、健全なユーザに大きな柔軟性を与えますが、同時に悪意のあるユーザにも与えてしまいます。
  • もし address.transfer を使ってEtherを送りたい場合、気を付けないといけないことがあります。
    1. もし受領者がコントラクトの場合、そのフォールバックファンクションが実行され、そのファンクションは次々にそのコントラクトにコールバックができます。
    2. コール深さが1024以上になるとEtherの送信は失敗する可能性があります。呼び出し元はコール深さの完全なコントロールを握っていますので、transferを失敗させることができます。この可能性を考慮に入れるか、send を使ってその返り値を常にチェックして下さい。できたら、受領者がEtherを代わりに引き出せるパターンをコントラクトを書くときに使って下さい。
    3. 割り当てられたガス以上のガスを受領コントラクトの実行で要求した場合もEtherの送信は失敗します(明示的に requireassertrevertthrow を明示的に使った場合、もしくは処理コストが高すぎるため)- "ガス不足"(OOG)になります。返り値チェックと、transfer もしくは send を使う場合、コントラクトの送信中に処理を止める方法を受領者に与えてしまうかもしれません。もう一度、ここでのベストプラクティスは "send" パターンの代わりに"withdraw" パターンを使うこと です。

Callstack Depth

1024の最大コールスタックを超える場合、externalのファンクションコールはどんな時も失敗します。この様な状況では、Solidityは例外を投げます。 悪意のあるユーザはあなたのコントラクトと繋がる前にコールスタックに大きい値を入れれるかもしれません。

コールスタックが使い尽くされた場合、.send() は例外を 投げません が、false を返すということを覚えておいて下さい。低レベルファンクションの``.call()``、.callcode().delegatecall().staticcall() も同じ様な挙動をします。

tx.origin

承認するのにtx.originは使わないでください。例えば、あなたが下記の様なウォレットコントラクトを持っていたとします。

pragma solidity ^0.5.0;

// THIS CONTRACT CONTAINS A BUG - DO NOT USE
contract TxUserWallet {
    address owner;

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

    function transferTo(address payable dest, uint amount) public {
        require(tx.origin == owner);
        dest.transfer(amount);
    }
}

今、誰かが下記の攻撃用ウォレットのアドレスにEtherを騙して送らさせます。

pragma solidity ^0.5.0;

interface TxUserWallet {
    function transferTo(address payable dest, uint amount) external;
}

contract TxAttackWallet {
    address payable owner;

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

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

もしあなたのウォレットが承認に msg.sender をチェックした場合、オーナーアドレスではなく、攻撃用ウォレットのアドレスを手に入れるでしょう。しかし、tx.origin をチェックした場合、トランザクションを始めたオリジナルのアドレスを取得しますので、それがオーナーアドレスになってしまいます。そして攻撃用ウォレットはすぐにファンドを全て取り出してしまいます。

Two's Complement / Underflows / Overflows

多数のプログラミング言語の様にSolidityの整数型は実際には整数ではありません。値が小さい時は整数に見えますが、値が大きくなると異なる挙動を示します。 例えば、次の式は真です: uint8(255) + uint8(1) == 0 これは overflow と言います。固定サイズの変数にその変数のデータ型の範囲外の数字(データ)を入れようとする演算が実行される時に起こります。underflow は逆の状況です: uint8(0) - uint8(1) == 255

一般に、2の補数表現の限度付近では符号付の数字のもっと特別なエッジケースがあります。

入力を合理的な範囲にサイズを制限するために require を使ってみて下さい。また、潜在的なオーバーフローを検知するために、SMT checker を、または全てのオーバーフローに対してrevertさせたい時は SafeMath を使って下さい。

require((balanceOf[_to] + _value) >= balanceOf[_to]) の様なコードは値が期待通りかチェックするのにも役立ちます。

Minor Details

  • 32バイトをフルで占有しない型は"汚れた上位ビット"を含んでいるかもしれません。これは msg.data にアクセスする場合、特に重要で、展性リスクがあります: あるファンクション f(uint8 x) を生のバイト引数 0xff0000010x00000001 で呼び出すトランザクションを作ることができます。両方ともコントラクトに渡され、x に関する限り、両方とも数字の 1 に見えますが、msg.data は違います。そのため、もし keccak256(msg.data) を使うと、異なる結果が得られます。

Recommendations

Take Warnings Seriously

もしコンパイラが何か警告を発したら、そこは変えたほうが良いです。 たとえ特別セキュリティに関係ないと思っても、その裏側では別の問題があるかもしれません。 コンパイラが発する警告は全て少しのコードの変更で消すことができます。

最近実装された全ての警告が出る様に、常に最新バージョンのコンパイラを使って下さい。

Restrict the Amount of Ether

スマートコントラクト内に保存されるEther(もしくは他のトークン)の量を制限してください。もしあなたのソースコード、コンパイラ、もしくはプラットフォームにバグがあったら、このファンドは全て失われるかもしれません。もし、ロスを制限したいのであれば、Etherの量を制限して下さい。

Keep it Small and Modular

コントラクトは小さくそして簡単に理解できる様にしておいて下さい。他のコントラクトもしくはライブラリの関係ない機能を見つけて下さい。もちろんソースコードのクオリティに関する一般的な勧告も使います: ローカル変数の量やファンクションの長さなどを制限して下さい。ファンクションに注記をつけて下さい。そうすれば他の人はあなたの意図が分かりますし、コードと意図が同じかどうかも分かります。

Use the Checks-Effects-Interactions Pattern

ほとんどのファンクションはまずいくつかのチェックで始まります(誰がファンクションを呼び出したのか、引数は範囲に入っているか、十分なEtherを送っているか、その人はトークンを持っているかなど)。これらのチェックは最初に終わらせなければいけません。

2つ目のステップとして、全てのチェックが通ったら、現在のコントラクトの状態変数の処理を行うはずです。 どんなファンクションでも他のファンクションとの相互作用は最後の最後に行われるはずです。

昔のコントラクトはいくつかの処理をデプロイしてからexternalのファンクションコールがノンエラーステートを返すのを待っていました。これは上記で説明したre-entrancy問題のため、しばしば大きな失敗となります。

さらに、既知のコントラクトをコールしても未知のコントラクトをどんどんコールする可能性があるということに気をつけて下さい。そのため、おそらく常にこのパターンを適用するのが良いでしょう。

Include a Fail-Safe Mode

システムを完全に非中央集権的にすると全ての仲介を除去することになる一方で、特に新しいコードにある種のフェイルセーフメカニズムを含めておくことは良いアイデアかもしれません。

"Etherのリークがあるか?"、"トークンの合計がコントラクトのバランスと一致するか"などの様なセルフチェックを実行するファンクションをスマートコントラクトに追加することができます。 大量のガスはこのために使えないことは頭に入れておいて下さい。おそらく、オフチェーンの計算が必要になるかもしれません。

セルフチェックが失敗したら、コントラクトは自動的にある種の"フェイルセーフ"モードに変わります。例えば、ほとんどの機能を無効にしたり、コントロールを決められたそして信頼できるサードパーティに渡したり、単にシンプルな"give me back my money"コントラクトに変換したりします。

Ask for Peer Review

たくさんの人がコードを調べれば、たくさんの問題が見つかります。 人に自分のコードのレビューを頼むことでそのコードが読みやすいかどうかクロスチェックすることになります。その読みやすさは良いスマートコントラクトの大切なクライテリアです。

Formal Verification

形式的検証を使って、あなたのコードがある形式の仕様を満たしているか証明する自動計算を行うことができます。 仕様は基準に則っています(ソースコードの様に)が、通常よりシンプルです

形式的検証自体はあなたがやった事(仕様)とどの様にやった(実際の実行)かの違いを理解するのに役立つだけです。そのため、仕様があなたのやりたかった事であるか、意図しない処理を見逃してないかどうかチェックする必要があります。