スマートコントラクトに対するリエントランシ攻撃とその対策
# 目次
- スマートコントラクトにおける「リエントランシ攻撃」の概要と原因を紹介します.
- 対策として、Checks-Effects-Interactionパターンを紹介します.
- コピペ実行可能なコードはこちらです.
# リエントランシ攻撃とは
スマートコントラクトを開発していると発生しがちな脆弱性に、リエントランシ攻撃に対する脆弱性があります.
本来であれば、関数を一度実行すると中断することなく上から下まで実行されます.
しかし、ある条件のもとでは関数の実行が途中で中断し、任意の処理を差し込めるようになります.
任意の処理なので、同じ関数をもう一度実行可能です.
その場合、実行中断中にもう一度頭から再実行されることになります.
そのため再入場=リエントランシ攻撃と呼ばれます.
ユーザーの通貨を保管する銀行系コントラクトを例にとります.
通貨を引き出すために、このコントラクトの通貨引き出し用関数を実行します.
着金を確認後、残高を減少させる処理の実行前に、何度も繰り返し同じ引き出し処理を実行させるのです.
すると、自分の残高を一切減少させずに銀行の全残高が尽きるまで引き出しを行うことができます.
これがリエントランシ攻撃です.
この記事では上記のある条件を満たしてしまうサンプルコードとその対策を紹介します.
# サンプルコード
# 銀行コントラクト(攻撃を受ける側)
まずは今回攻撃される銀行コントラクトです.
基本的な残高管理・預金・引き出しの機能をもっています.
balance
にAttacker
の引き出し可能残高が記録されています.
(本番ではユーザーごとの残高の連想配列になっているはずです.)
特に引き出し関数に注目してください.
以下の順で処理されています.
- 残高が引き出し要求額よりも多いかチェック(足りなければ処理停止)
- 送金
- 送金した額面だけ残高を減少
/**
* 攻撃される銀行
*/
contract Bank {
/**
* 引き出し可能な残高
*/
uint256 public balance = 0;
/**
* 引き出し
*/
function withdraw (uint256 amount) public {
// 1. 実行条件チェック
require(amount <= balance);
// 2. 振込
Attacker(msg.sender).fallback(amount);
// 3. 残高更新
balance -= amount;
}
/**
* 預金
*/
function deposit(uint256 amount) public {
balance += amount;
}
}
# 攻撃用コントラクト
次に攻撃用のコントラクトです.
今回は実際のETHを移動させず数字上でシミュレーションをします.
fallback
関数が重要です.
SolidityではETHの着金時にフォールバック関数という無名関数を自動で呼び出すことが出来ます.
これをシミュレートするものです.
送金時にこのfallback
を呼ぶこととします.
フォールバック関数内では任意の処理を実行出来るので、引き出しを再度呼び出します.
無限に繰り返すとエラーになってしまうので、ここでは10回実行することにします.
/**
* 攻撃者
*/
contract Attacker {
Bank bank;
/**
* 保有する資金
*/
uint256 public balance = 0;
/**
* 一度の引き出しで振込された回数
*/
uint256 public count = 0;
/**
* 一度の引き出しのログ
*/
event Log(uint256 count, uint256 balance, uint256 bankBalance);
constructor () public {
// バンクを新規作成
bank = new Bank();
// 10000 wei 預金
bank.deposit(10000);
}
/**
* 送金時に実行される.
* amount を受け取る.
*/
function fallback (uint256 amount) public {
count++;
balance += amount;
emit Log(count, balance, bank.balance());
// 引き出し可能残高の10倍でやめてあげる.
if (count >= 10) {
count = 0;
return;
}
// 着金時に再度引き出しすると...
bank.withdraw(amount);
}
/**
* 攻撃開始
*/
function attack() public {
withdraw(10000);
}
/**
* Bankから引き出し
*/
function withdraw(uint256 amount) public {
bank.withdraw(amount);
}
/**
* Bankに預金
*/
function deposit(uint256 amount) public {
bank.deposit(amount);
}
/**
* Bankの残高チェック
*/
function bankBalance() public view returns (uint256) {
return bank.balance();
}
}
# 実行結果
まず攻撃者はAttacker.attack()
で攻撃を開始します.
bank.withdraw(10000)
が呼び出されます.
残高は引き出し要求額以上(Bankには10000振込み済み)あります.
よって、BankからAttackerへ10000が送金されます.
ここでAttackerに処理が移るため、残高更新処理が行われないことがポイントです.
Attackerに送金があったのでフォールバック関数が呼び出されます.
再びbank.withdraw(10000)
が呼び出されます.
Bankが管理しているAttackerの残高に変更はないのでまだ10000のままです.
残高は引き出し要求額以上なので、BankからAttackerへ10000が送金されます.
10回繰り返すまで、Attackerに送金され続けます.
これがリエントランシ攻撃です.
Remix上でattackを実行した結果です.
残高は10000だったはずなのに、確かに100000振り込まれていますね.
そして、Bank側の残高はアンダーフローを起こしています.
これは10000に対して-10000
が10回実行されてしまったためです.
# 原因
残高を更新する前に、送金してしまっていることが原因です.
送金すると、それをトリガーとして外部でどんな処理が実行されるか分かりません.
# 対策
送金を関数の一番最後に移動することで防ぐことができます.
残高更新を先にしておくと、関数を再実行されたとしても残高が減少していくので、自分の残高以上には引き出せなくなります.
この安全な処理順序のパターンにはChecks-Effects-Interactionという名前が付いています.
- 関数の実行権限や前提条件のチェック
- コントラクト内の状態の更新
- 外部コントラクト/アドレスに対する処理
という順番で実行するようにします.
また、再入場されても問題ない関数の性質をリエントラントと呼びます.
# 対策後のサンプルコード
以下のようになります.
残高の更新と送金処理を入れ替えただけです.
contract Bank {
/**
* 引き出し可能な残高
*/
uint256 public balance = 0;
/**
* 引き出し
*/
function withdraw (uint256 amount) public {
// 1. 実行条件チェック
require(amount <= balance);
// 2. 残高更新
balance -= amount;
// 3. 振込
Attacker(msg.sender).fallback(amount);
}
/**
* 預金
*/
function deposit(uint256 amount) public {
balance += amount;
}
}
# まとめ
- 関数の実行中に再び関数を頭から実行させる攻撃をリエントランシ攻撃と呼びます.
- 対策は、Checks-Effects-Interactionパターンに沿って実装し関数をリエントラントにすることです.
リエントラント攻撃と対策の紹介は以上です.
次回は、そもそも関数を再実行させない方法を紹介します.