失敗に備える コントラクトの更新

Yuya Sugano
23 min readNov 13, 2018

--

セキュリティトークンが相当盛り上がっています。来年はSTO元年になりそうな予感です。Ethereum上のコントラクトは、ネットワークへデプロイすると基本的に書き換えることができません。デプロイ済みコントラクト上のセキュリティリスクへどう備えるか、特にコントラクトのアップグレードについて公開されている手法を記事にしてみました。

リスクへの備え

ConsenSysによるEthereumスマートコントラクトベストプラクティスでは、コードが安全かどうか事前に知ることは不可能である、との前提から失敗に備えるアプローチとしてどのような方法があるのか紹介されています。リスクとは一般的に「発生し得る不確実性」を指しますが、スマートコントラクトのリスク対応においては、そのリスクレベルを低減するための事前対応、またはそのリスクにより発生した障害、顕在化したバグを復旧・改善させる事後対応に分けて考えることができると思います。

ConsenSys Smart Contract Best Practices(英語) [1]
https://consensys.github.io/smart-contract-best-practices/software_engineering/

ConsenSys Smart Contract Best Practices(日本語訳)[2]
https://msykd.github.io/smart-contract-best-practices/software_engineering/

リスク低減を目的とする事前対応には以下のように監査やテストなどが含まれます。

  • コードセキュリティのAuditを外部会社に依頼する
  • 十分に計画されたコントラクトロールアウト

コントラクトロールアウトに際してやるべきこと(テストケースを漏れなく重複なく行うことは実際難しいと思います)。

  • 100%のテストカバレッジを持つ完全なテストスイートの作成
  • 独自のテストネットに展開する
  • テストとバグバウンティプログラムを組みパブリックなテストネットに展開する(バグバウンティプログラム [3]
  • テストにおいて様々なプレイヤーがコントラクトとやり取りできるようにする
  • ベータ版などでメインネット上に展開し、リスクの大きさを制限する

障害発生もしくはバグが発見されたあとに有効な対策や復旧には以下のような対応が考えられます。

  • サーキットブレーカー(コントラクトの一時停止)
  • スピードバンプ(遅延契約アクション)
  • コントラクトのアップグレード

上記3つの事後対応を個別に確認していきます。

サーキットブレーカー(コントラクトの一時停止)

バグを突いた攻撃が行われたとき、コントラクトを停止することは効果的です。過去いくつかの事例では、トークンサプライの変動で攻撃に気付いた例がありました。管理者などの特定の関係者にサーキットブレーカーをトリガーする権限を与えるか、 特定の条件が満たされたときにサーキットブレーカーを自動的にトリガーするプログラムをコントラクトに与えることができます。以下のコード例ではコントラクトの所有者がサーキットブレーカーをトリガーすることができるようになっています。コントラクトの停止は効果的ではあると思いますが、コントラクトの修復など、バグを取り除く根本的な対応を行わない限り再度コントラクトの処理を開始することができません。

bool private stopped = false;
address private owner;

modifier isAdmin() {
require(msg.sender == owner);
_;
}

function toggleContractActive() isAdmin public {
// You can add an additional modifier that restricts stopping a contract to be based on another action, such as a vote of users
stopped = !stopped;
}

modifier stopInEmergency { if (!stopped) _; }
modifier onlyInEmergency { if (stopped) _; }

function deposit() stopInEmergency public {
// some code
}

function withdraw() onlyInEmergency public {
// some code
}

スピードバンプ(遅延契約アクション)

スピードバンプはコントラクトの処理実行を遅延させます。仮に攻撃が発生した場合においても、対象の処理実行までの猶予があるため、コントラクト修復などの対応が可能となります。 The DAO Hackにおいて攻撃者はDAO分割処理の後、資金を引き出すまで27日間待たなければいけませんでした。DAOのケースでは与えられた猶予時間内に有効な対応策はなく、結果的にEthereumのハードフォークという結末になりましたが、スピードバンプは他のテクニックと組み合わせて、非常に効果的だと考えられます。

struct RequestedWithdrawal {
uint amount;
uint time;
}

mapping (address => uint) private balances;
mapping (address => RequestedWithdrawal) private requestedWithdrawals;
uint constant withdrawalWaitPeriod = 28 days; // 4 weeks

function requestWithdrawal() public {
if (balances[msg.sender] > 0) {
uint amountToWithdraw = balances[msg.sender];
balances[msg.sender] = 0; // for simplicity, we withdraw everything;
// presumably, the deposit function prevents new deposits when withdrawals are in progress

requestedWithdrawals[msg.sender] = RequestedWithdrawal({
amount: amountToWithdraw,
time: now
});
}
}

function withdraw() public {
if(requestedWithdrawals[msg.sender].amount > 0 && now > requestedWithdrawals[msg.sender].time + withdrawalWaitPeriod) {
uint amountToWithdraw = requestedWithdrawals[msg.sender].amount;
requestedWithdrawals[msg.sender].amount = 0;

require(msg.sender.send(amountToWithdraw));
}
}

コントラクトのアップグレード

実際に障害が発生しているまたはバグが発見された状況では、コントラクトの停止や処理の遅延だけでなく、いずれコントラクト自体を改修する必要が出てくるはずです。コントラクトを停止することで被害は止められますが、サービスを実運用している場合ではサービス自体も止めることになってしまいます。

ConsenSysではコントラクトをレジストリコントラクトで運用する方法が紹介されていますが、ユーザが最新のコントラクトのアドレスをレジストリコントラクトに問い合わせる方法を考える必要があります(ENSなどが使用できる可能性あり)。以下の記事で紹介されているキーストレージコントラクト、委譲コントラクト、プロキシーコントラクトによるロジックとデータの分離が現状良いパターンに見えたので内容の要約とサンプルコードのご紹介をします。[4]

  • 基本コンセプトはロジックとデータの分離
  • データをkey-value形式でキーストレージコントラクトへ保持
  • ロジックをアップグレード可能なコントラクトデザイン(委譲コントラクト)
  • プロキシーコントラクトを用いた委譲(delegatecallでの呼び出し)

実装のポイント:

  • 更新後のコントラクトと元のコントラクトの状態を保持する必要がある
  • コントラクトのロジックとデータ部分を分離する、それによって複数のバージョンがコントラクトのデータを共有できるようになる
  • プロキシーコントラクトをイミュータブルなストレージ用のコントラクトとする、委譲コントラクト側でアプリケーションロジックを実装する
  • プロキシーコントラクト、委譲コントラクトは両方のストレージ構造が同じでなければならない
  • 委譲コントラクトのロジックを更新する際には、プロキシーコントラクトに新しい委譲コントラクトのアドレスを通知する必要がある
  • プロキシーコントラクトは、delegatecallを用いてEVMコードを委譲コントラクトに対して代理実行する

このサンプルでは、以下のコントラクトの実装が必要です。それぞれ内容を転写します。

  • キーストレージコントラクト
  • 委譲コントラクト(V1およびV2)
  • プロキシーコントラクト

キーストレージコントラクト

キーストレージコントラクトを共通ストレージとして、全てのバージョンの委譲コントラクトから参照できるようにします。またgetter/setter関数を用意して委譲コントラクトから値を取得・変更できるようにします。委譲コントラクトに実際のアプリケーションロジックが実装されていて、委譲コントラクト側からキーストレージコントラクトを参照できる形を想像してください。

key-valueの形式で必要となる型のマッピングを用意します。以下は、uint型のkey-value型の宣言とgetter/setter関数の例です。例えば、トークン発行数量としてお馴染みのtotalSupplyという名前のuint型の変数が必要な場合は、setUintStorage関数のkeyFieldをtotalSupplyとして呼び出すことでvalueにtotalSupplyの値を設定できるようになります。

mapping(address => mapping(bytes32 => uint)) uintStorage

function getUint(bytes32 key) public view returns (uint) {

return _uintStorage[msg.sender][key];

}

function setUintStorage(bytes32 keyField,uint value) public {

uintStorage[msg.sender][keyField] = value;

}

キーストレージコントラクト(KeyStorage.sol)のサンプルです。いまuint型、address型、bool型がデータとして必要だと想定します。

pragma solidity 0.4.23;contract KeyValueStorage {

mapping(address => mapping(bytes32 => uint)) _uintStorage;
mapping(address => mapping(bytes32 => address)) _addressStorage;
mapping(address => mapping(bytes32 => bool)) _boolStorage;

// Get Methods
function getAddress(bytes32 key) public view returns (address) {
return _addressStorage[msg.sender][key];
}

function getUint(bytes32 key) public view returns (uint) {
return _uintStorage[msg.sender][key];
}

function getBool(bytes32 key) public view returns (bool) {
return _boolStorage[msg.sender][key];
}

// Set Methods
function setAddress(bytes32 key, address _value) public {
_addressStorage[msg.sender][key] = _value;
}

function setUint(bytes32 key, uint _value) public {
_uintStorage[msg.sender][key] = _value;
}

function setBool(bytes32 key, bool _value) public {
_boolStorage[msg.sender][key] = _value;
}
}

委譲コントラクト

委譲コントラクトには実際のDappsのアプリケーションロジックおよびキーストレージコントラクトのローカルコピーが含まれます。例としてここではV1が既にデプロイされていたコントラクトで、バグが発見され、V2を後でデプロイするケースを考えます。

V1として以下のようなコードをデプロイします(DelegateV1.sol)。setNumberOfOwners関数は外部のどのアドレスからでも呼び出し可能ですが、この関数は本来コントラクトの所有者からしか呼び出せないような設計であったとします。ブロックチェーン上にデプロイ済みのコントラクトは変更できないため、通常であれば同じstorage構造を定義した別のコントラクトをデプロイし、delegatecallなどを利用してデータを転送する方法が考えられます。

pragma solidity 0.4.23;import "./SafeMath.sol";
import "./StorageState.sol";
contract DelegateV1 is StorageState {
using SafeMath for uint256;

function setNumberOfOwners(uint256 num) public {
_storage.setUint("totalSupply", num);
}

function getNumberOfOwners() view public returns (uint256) {
return _storage.getUint("totalSupply");
}
}

ここではデータを既にキーストレージコントラクトへ分離させているので、ロジックだけを書き直したV2のコントラクトを以下のようにデプロイします(DelegateV2.sol)。委譲コントラクトからキーストレージコントラクトを参照することで、ロジックが変更されてもデータを移転する必要がありません。

pragma solidity 0.4.23;import "./SafeMath.sol";
import "./StorageState.sol";
contract DelegateV2 is StorageState {
using SafeMath for uint256;

modifier onlyOwner() {
require(msg.sender == _storage.getAddress("owner"));
_;
}

function setNumberOfOwners(uint256 num) public onlyOwner {
_storage.setUint("totalSupply", num);
}

function getNumberOfOwners() view public returns (uint256) {
return _storage.getUint("totalSupply");
}
}

委譲コントラクトV2でロジックはアップグレードできましたが、1つ問題が残っています。ユーザはDappなどアプリケーションの呼び出しの際に新しいコントラクトのアドレスを呼び出す必要がありますが、ユーザは新しいコントラクトのアドレスを知りません。これを次のプロキシーコントラクトで解決します。

プロキシーコントラクト

プロキシーコントラクトはdelegatecallを利用して、関数呼び出しを対象のコントラクトへ転送するために作成します。委譲コントラクトのロジックは都度必要に応じてアップグレード可能で、新しい委譲コントラクトのアドレスをプロキシーコントラクト内で指定するようにします。msg.senderはプロキシーコントラクトの呼び出し元になるため、ユーザ側は委譲コントラクトのアドレスの変更を意識する必要が全くなくなります。

A delegate can dynamically load code from a different address at runtime. Storage, current address and balance still refer to the calling contract, only the code is taken from the called address.
ランタイムでコードを別アドレスから読み込むこと、呼び出すことができます。ストレージ、現在のアドレスやバランスは呼び出し元のコントラクトを参照し続けますが、呼び出し先のコードだけが対象の委譲コントラクトから呼び出されます。

SO we just need to pass the address of new version of contract to proxy contract via upgradeTo function.
新しい委譲コントラクトのアドレスをプロキシーコントラクトへ渡すだけでロジックをアップグレードできます。

プロキシーコントラクト(ProxyContract.sol)のサンプルです。

pragma solidity 0.4.23;import "./SafeMath.sol";
import "./StorageState.sol";
contract Proxy is StorageState {
modifier onlyOwner() {
require(msg.sender == _storage.getAddress("owner"));
_;
}

constructor(KeyValueStorage storage_ , address _owner) public {
_storage = storage_;
_storage.setAddress("owner", _owner);
}

event Upgraded(address indexed implementation);

address public _implementation;

function implementation() public view returns (address) {
return _implementation;
}

function upgradeTo(address impl) public onlyOwner {
require(_implementation != impl);
_implementation = impl;
emit Upgraded(impl);
}

function () payable public {
address _impl = implementation();
require(_impl != address(0));
bytes memory data = msg.data;

assembly {
let result := delegatecall(gas, _impl, add(data, 0x20), mload(data), 0, 0)
let size := returndatasize
let ptr := mload(0x40)
returndatacopy(ptr, 0, size)
switch result
case 0 { revert(ptr, size) }
default { return(ptr, size) }
}
}
}

delegatecallは、add(data, 0x20)を_implの指すアドレスで呼び出します。_implはimplementation関数で与えられますが、委譲コントラクトである呼び出し先のアドレスを指定して実装ロジックを呼び出せるようになっています。delegatecallはエラーが発生すると0、成功の場合は1が戻り値です。委譲コントラクト内のロジックの処理はすべてプロキシーコントラクトからのdelegatecallで呼び出されますが、そのためには委譲コントラクトとプロキシーコントラクトのstorage構造を同一にする必要があります。そのためにStorageStateコントラクトを以下のように拡張して、読み込んでいます(StorageState.sol)。

pragma solidity ^0.4.23;
import "./KeyStorage.sol";
contract StorageState {
KeyStorage _storage;
}

ここまでの実装でユーザはDappsなどアプリケーションロジックをすべてプロキシーコントラクト経由で実行できるようになりました。またキーストレージコントラクトに保持されているデータは全ての委譲コントラクトから参照可能で、プロキシーコントラクトから委譲コントラクトを呼び出すことでgetter/setter関数が実行できます。プロキシーアドレスの所有者だけが、委譲コントラクトのアドレスを更新することができるように制限されています。サイトに記載されているテストコードをtruffleで書き直してGitHubに掲載しているので興味がある方は見てみてください。

https://github.com/yuyasugano/updatable-solidity

contract("Updatable-solidity", async function(accounts) {it("should create and upgrade the delegate contract", async function() {
var initialNumber = 10;
var updatedNumber = 20;
let keyStorage = await KeyStorageContract.deployed();
let delegateV1 = await DelegateV1Contract.deployed();
let delegateV2 = await DelegateV2Contract.deployed();
let proxyContract = await ProxyContract.deployed();
// Change _implementation to delegateV1 address
await proxyContract.upgradeTo(delegateV1.address);
proxyContract = _.extend(proxyContract, DelegateV1Contract.at(proxyContract.address));
// Call setNumberOfOwners function and set a number
await proxyContract.setNumberOfOwners(initialNumber);
let numOwnerV1 = await proxyContract.getNumberOfOwners();
// Change _implementation to delegateV2 address
await proxyContract.upgradeTo(delegateV2.address);
proxyContract = DelegateV2Contract.at(proxyContract.address);
// Call getNumberOfOwners function and get the number that was modified by DelegateV1
let previousOwnerState = await proxyContract.getNumberOfOwners();
// Call setNumberOfOwners function and set a number
await proxyContract.setNumberOfOwners(20, {from:accounts[0]});
let numOwnerV2 = await proxyContract.getNumberOfOwners();
assert.equal(previousOwnerState.toNumber(), numOwnerV1.toNumber(), "Initial number changed after the contract upgraded");
assert.equal(numOwnerV2.toNumber(), updatedNumber, "Updated number was wrong after the contract upgraded");
});
});

以上です。

まとめ

  • セキュリティリスクに備えるために事前対応、事後対応を実施しておくことは重要である
  • コード監査やコントラクトロールアウトの綿密な計画が事前対応として考えられる
  • サーキットブレーカー、スピードバンプ、コントラクトのアップグレードなどが事後対応として考えられる
  • キーストレージコントラクト、委譲コントラクト、プロキシーコントラクトでロジックとデータを分離することはコントラクトのアップグレードに有効な方法である

--

--

Yuya Sugano
Yuya Sugano

Written by Yuya Sugano

Cloud Architect and Blockchain Enthusiast, techflare.blog, Vinyl DJ, Backpacker. ブロックチェーン・クラウド(AWS/Azure)関連の記事をパブリッシュ。バックパッカーとしてユーラシア大陸を陸路横断するなど旅が趣味。

No responses yet