9-1: スマートコントラクトへの攻撃手法と対策
スマートコントラクトは単なるプログラムです。
一旦、メインネットにデプロイされてしまえば、どのようなユーザーでも利用することができます。
コントラクトのソースコードは公開することが推奨されていますが、公開されていなくともetherscanにアドレスを問い合わせれば、誰でも参照可能です。
そのため、常に攻撃を受けるものと認識しましょう。
よく知られている攻撃手法を紹介します。
リエントラント(再入可能)
外部のコントラクトを呼び出す際、呼び出した関数が期待していないデータを変更してしまうことがあげられます。
TheDAO事件はこのバグを突かれています。
呼び出した関数の処理が終わらないうちに、他の関数を呼ばれてしまい、開発者の意図しない動作が発生してしまったのです。
残高を減らす前に送金してしまうコードが問題でした。
再現方法
攻撃者は送金処理を呼び出し、送金を受け取って、さらに送金処理を呼び出すコントラクトを作成します。
循環的に送金処理を行い払い出すEtherがコントラクト内になくなるか、設定されたGas Limitに達するかで、送金処理がエラーになるまで続きます。
TheDAO事件では、トークンの払い戻し処理の際に、攻撃するコントラクトからトークンの量を増やす処理を呼ぶことで、払い戻し処理をトークンの数量を増やしながら何度も行われました。
この悪意ある攻撃に対する解決策の1つとして、競合状態にある変数を不用意に変更されないようにミューテックスを利用する方法があります。
コントラクトを呼び出したいアカウントだけが変更可能なロック情報を作ることで、不意のメソッドが呼ばれるのを防ぐ方法です。
残高変更フラグでのロック状態を作るか、残高を先に変更してから送金処理を行うようにするなどの方法で対応しましょう。
トランザクションオーダー依存(TOD)
トランザクションオーダー依存(TOD: Transaction Order Dependence)問題は、異なるユーザーがコントラクトを操作する場合、操作対象のトランザクションを送信した順序と、実際にブロックに取り込まれる順序が前後してしまい、意図通りに動作しないことです。
以下で、オークションコントラクト(AC)を例に考えてみます。
売り手が売りたいアイテムを1etherでACに登録
買い手はACにアイテムを購入するトランザクションを発行
売り手はアイテムの金額を2etherにするようにACの情報を更新
②→③の順番でトランザクションに取り込まれた場合、買い手はアイテムを1etherで購入できます。
しかし、ブロックチェーンは実時間とは違う時間が流れており、トランザクションは発行した時刻がどうであれ、ブロックに取り込まれる順番でしか前後関係を把握できません。
②→③の順番ではなく③→②の可能性もあります。
全てのトランザクションはGasを利用します。
Gasはブロック生成者の報酬となるため、各々のマイナーはGasが多いトランザクションを優先して取り込みます。
つまり、②で発生したトランザクションを売り手が検知することができれば、即座に③のトランザクションをGasを多めにして発行することで、買い手にアイテムを2etherで売ることが可能です。
そもそも、トランザクションの順序はマイナーに依存するため、この問題を防ぐ手立てはありません。
このケースでは、Webアプリケーションなどを前段に配置して、金額の更新は現在発行されている購入トランザクションがすべて完了後に実施するなどの対策を取る必要があるでしょう。
金融業界では同様の問題をフロントランニングと呼びます。
フロントランニングとは、顧客から売り注文が入った際に、売り注文が入ったことを証券会社が知ることで、事前に証券会社自らが売り抜けてしまうことを指します。
トランザクションオーダー依存の問題は、このフロントランニングの状態を引き起こしてしまいます。
また、トランザクションオーダー依存の問題は、トランザクションの送信アドレスが同一の場合には発生しません。
トランザクションが発行するたびに、アカウントに紐付くnonceは1つずつインクリメントされます。
同一アドレスからのトランザクションでは、このnonceで前後関係が判断されるため、意図しないトランザクションの順序入れ替えは発生しません。
タイムスタンプ依存
ブロックのタイムスタンプに依存する処理を記述する際、細心の注意が必要です。
ブロックのタイムスタンプは、ノードがブロックを生成した時刻です。
トランザクション発行時の時刻ではないので、各ノードに依存してしまいます。
タイムスタンプは、操作できる可能性があることを覚えておきましょう。
整数オーバーフロー
符号なし整数で表現される変数は注意しないと、オーバーフローする可能性があります。
オーバーフローを避けるためには、符号なし整数の型を大きくするか、または最大数に達するかどうかを判定する必要があります。
予期しないrevert
revertはコントラクトが失敗に終わった時に実行前に巻き戻すの処理です。
しかし、悪意あるユーザーによって、巻き戻しが必ず実行されるようになる可能性もあります。
ロジックに実行者の状態に依存する処理を含めることは避けましょう。
contract Auction { | |
address currentLeader; | |
uint highestBid; | |
function bid () payable { | |
require (msg.value > highestBid); | |
require (currentLeader.send(highestBid)); | |
currentLeader = msg.sender; | |
highestBid = msg.value; | |
} | |
} |
上記のコード例は簡単なオークションのコントラクトです。
新しい入札者がbid()
を呼び出した際の流れを説明します。
1つ目のrequireで現在の最高入札金額よりも新しい入札額が大きいかを確認します。
大きかった場合、2つ目のrequireで現在の入札者に現在の入札金額を返金します。
返金後は、新しい入札者は現在の入札者となり、新しい入札者が送金した金額が現在の最高入札金額になります。
このロジックでは、2つ目のrequireが問題となります。
悪意のあるコントラクト経由で入札しており、返金処理が必ず失敗する場合、このコントラクトは必ずトランザクション実行前に巻き戻ります。
つまり、誰も入札できない状態となります。
この状態を回避するために、「Pull Payment System」と呼ばれる返金方法に変更するべきです。
各々のユーザーに対する返金額を保持しておき、その金額を返却します。
contract Auction { | |
address currentLeader; | |
uint highestBid; | |
mapping (address => uint) usersBalance; | |
function bid () payable { | |
require (msg.value > highestBid); | |
// 現在の最高価格入札者の返金額を更新する | |
usersBalance[currentLeader] += highestBid; | |
currentLeader = msg.sender; | |
highestBid = msg.value; | |
} | |
function withdraw() { | |
require(usersBalance[msg.sender] > 0); | |
// 返金額を取得 | |
uint amount = usersBalance[msg.sender]; | |
// 送金 | |
assert(msg.sender.send(amount)); | |
} | |
} |
ブロックのGas Limit
ブロックにもGas Limitがあります。
トランザクションは実行された結果と共にブロックに入りますが、ブロックのGas Limitを超える場合は必ず失敗します。
例えばループ処理を実装していたとして、悪意のある攻撃者が必ずブロックのGas Limitを超えるようにしておけば、ループ処理は失敗します。
悪意のある攻撃者だけではなく、意図せず記述してしまう可能性もあるので、ループ処理には細心の注意を払いましょう。
強制的な送金
前述のリエントラントで説明したfallbackを使った脆弱性以外にも、ehterを送ることが可能です。
contract Vulnerable { | |
function () payable { | |
revert(); | |
} | |
function doSomething() { | |
require(this.balance > 0); | |
// この下のコードが実行される可能性があります | |
} | |
} |
上記のコードでは、送金時に動作するfallback関数内にrevert()
を指定しているので、必ずトランザクション実行前の状態に戻ります。
このコントラクトが持つ残高がゼロの場合、doSomething()
は残高をチェックしているので、それ以降の処理は実行されません。
しかし、このコントラクトに送金することは別経路でも可能です。
それはコントラクトを破棄するselfdestruct
を呼ぶことで実現できます。
selfdestruct
は引数にアドレスを指定し、その時点でコントラクトが所持しているetherをアドレス宛に送ります。
受け取り側がコントラクトでfallback関数を指定していたとしてもfallback関数は動作しません。
また、コントラクトがデプロイされる前に、コントラクトのアドレストォ予測して前もって送金することでも残高を増やすことができます。
このような可能性に留意してロジックを実装しましょう。