Ethereumで安全なスマートコントラクトを書くのに最低限気をつける4つの点
暗号通貨に対するハッキングは近年増加しつつあることが知られています。 本記事では安全なスマートコントラクトを書くにあたり、4 つの主要な攻撃方法とその対策をコードサンプルとともに紹介します。
1. オーバーフロー
オーバーフローとは与えられたビット数で表現できる数の範囲を超えてしまった場合に起こる現象のことを指します。たとえば、Solidity の符号なし整数 (uint) は 256 bit の非負整数であり、0 以上 2²⁵⁶ −1 以下の整数のみ表します。この場合、0 − 1 の結果は −1 ではなく 2²⁵⁶ −1 となってしまいます。
mapping (address => uint) balance // 残高function withdraw(uint amount) {
balance[msg.sender] -= amount
msg.sender.transfer(amount)
}
上の例ではbalance[msg.sender]
が 0 の状態で withdraw(1)
を呼ぶと残高 balance[msg.sender]
が 2²⁵⁶ − 1 なってしまいます。このような意図しない計算結果は、金銭的な取引を扱うことの多いスマートコントラクトでは致命的な損害につながる可能性があります。そのため、四則演算を扱う場合は OpenZepplin が公開している SafeMath を使用しましょう。a — b
の計算を行う前にa >= b
であることや乗算の結果が 2²⁵⁶ を超えてないことなどをチェックしてくれます。
対策 1. 四則演算には SafeMath を使用する
2. send() vs transfer() vs call.value()
スマートコントラクトにおける Ether の支払い方法には以下の 3 つの方法があります。
- gas limit が 2300 に設定されている
- 失敗すると revert する(コントラクトの状態が実行前に戻る)
address.send(amount)
- gas limit が 2300 に設定されている
- 失敗すると false を返す
address.call().value(amount).gas(gasLimit)()
- Gas limit を設定することが可能
- 失敗すると false を返す
Gas limit を自分で設定する特別な理由がない限りは transfer または send を使用しましょう。Gas limitをカスタムで設定することにより、以下で述べる Reentrant 攻撃や GasToken Minting といった脆弱性につながる可能性があるからです。また、send を使う場合は Ether 送金に失敗した場合の処理を書かないと脆弱性となる場合があります。
対策 2. 特別な理由がない限り、Ether を送るには transfer を使用する
3. Reentrant 攻撃
Reentrant 攻撃は、トランザクションが終結する前に再びトランザクションを起こすことにより予期していない動作を起こす攻撃です。この手法を用いて、DAO (Decentralized Autonomous Organization) が 6000 万ドルも盗まれたことから The DAO 攻撃とも呼ばれます。
Reentrant 攻撃を理解するにはまず fallback 関数について理解する必要があります。Ethereumのシステムでは Solidity をコンパイルしたEVM Codeと呼ばれる専用のバイトコードが使用されています。EVM Code には関数は定義されていませんが、関数の名前と引数のタイプをハッシュ関数に入力することで得られるユニークな IDを関数に対応させ、このIDを指定することで対応した関数を利用できます。たとえば、以下のコードはコントラクト a
の foo
という関数を引数 10
で呼び出します。
// コントラクト B の関数 bar は コントラクト A の関数 foo を引数 10 で呼ぶ
contract A { function foo(uint) returns (uint) } // インターフェース
contract B { function bar(A a) { a.foo(10); } }// 上記の関数 bar の中身と同じ
// 関数 foo の ID は sha3("foo(uint256)")
a.call.value()(bytes4(sha3("foo(uint256)")), 10);
上のように、コントラクトの関数は対応する ID から呼ぶことができますが、対応する ID が存在しない場合や送金がなされた場合は fallback 関数が代わりに呼ばれます。fallback 関数は下の例の通り、名前のない関数です。
contract Shop {
public mapping (address => uint) owns;
public uint price = 1;
function buy() external payable {
require(msg.value >= price)
// 過剰なEtherは返金する
if (msg.value > price) {
msg.sender.transfer(msg.value.sub(price)); // (2)
}
owns[msg.sender] = owns[msg.sender].add(1); price = price.mul(2); // (5) 買われるたびに値段が倍に
}
}contract Attacker {
public bool ready = true;
public Shop shop; function attack() {
shop.value(1000).buy(); // (1)
} // fallback 関数
function () { // (3)
if (ready) {
ready = false;
shop.value(1).buy(); // (4)
}
}
}
この例で示したShop コントラクトではアイテムが購入されるたびに値段(price)が倍になる実装がされています。しかしReentrant 攻撃を用いると値段が倍増することなくアイテムを複数購入することができてしまいます。攻撃の手順は以下の通りです。
- 攻撃側は Attacker コントラクトの attack を呼びます 。
- attack 関数がEtherを過剰に払っている場合、返金が生じます (2)。
- Attacker コントラクトに送金が生じるため、fallback 関数が呼び出されます。
ready
は true になっているため、buy
トランザクションが再び生じます (4)。
ここまでで注意するべきは、二回目の購入の時点で値段の更新 (5) がされてない点です。また、fallback 関数を書き換えることにより、buy
を値段倍増の前に任意の回数呼び出すことができます。Ether を預けて引き出すといったコントラクトの場合には、コントラクト内の Ether をすべて盗まれてしまう可能性があるので注意してください。
- Attacker コントラクトに送金が生じるため、fallback 関数が呼び出されます。
ready
は true になっているため、buy
トランザクションが再び生じます (4)。注意していただきたいのは、二回目の購入の時点で値段の更新 (5) がされてない点です。また、fallback 関数を書き換えることにより、buy
を値段倍増の前に任意の回数呼び出すことができます。Ether を預けて引き出すといったコントラクトの場合には、コントラクト内の Ether をすべて盗まれてしまう可能性があるので注意してください。
対策は複数あります。
- OpenZepplin が公開している ReentrancyGuard を使用する。2018/11 現在 ReentrancyGuard の実装は以下のようになっています。関数の実行ごとにインクリメントされる
_guardCounter
が増加していた場合、revert が生じる仕組みとなっています。
contract ReentrancyGuard {
uint256 private _guardCounter;
modifier nonReentrant() {
_guardCounter += 1;
uint256 localCounter = _guardCounter;
_;
require(localCounter == _guardCounter);
}
}
- transfer や send を使用する。Gas Limit が 2300 であれば、fallback 関数内で再度トランザクションを起こすといったこともできません。
- Ether の送金の前に状態の更新 (今回であれば値段の倍増) を行う。2 回目以降呼ばれたとしても、状態が更新済みであれば脆弱性とはなりません。
対策 3. Ether 送金を伴う関数には ReentrantCuard を使用する
4. Delegatecall
Delegatecall 関連の脆弱性により Parity Wallet Hack という 3000 万ドルが盗まれるハッキング事件がありました。
Delegatecall はコンテクストをキープしたまま他のコントラクトの関数を呼ぶ仕組みです。Solidity のライブラリなどは裏で Delegatecall が使用されています。Delegatecall に任意のデータ (msg.data
など) を渡すことができる場合、任意の external 関数を呼びだすことが可能となってしまい脆弱性となる可能性があります。なお、Delegatecall について詳しくは、こちらを参照ください。
対策 4. 必要ないのであれば、Delegatecall を使わないシステムの設計する
まとめ
本記事ではスマートコントラクトに対する 4 つの主要な攻撃方法とその対策をまとめました。これらの項目は安全なスマートコントラクトを書くためには最低限守るべきものです。しかし、スマートコントラクトに対する攻撃は他にも多く知られています。たびたび、ConsenSys のリストや SWC Registry (既知の脆弱性を網羅しているリスト) をチェックすることを推奨します。