💹

Upgradeability - Proxy Patternについて

2022/08/18に公開約13,900字

8月の東京は非常に暑い日々が続いており、いかがお過ごしでしょうか。JPYC研究開発チーム所属のエアコンをつけずにはいられないThurendousです。そんな中でやる気の起伏はあるものの、何事も「塵も積もれば山となる」というので、ブロックチェーンの勉強は欠かさず毎日精進しているところです。また、最近ですとnomadブリッジ、solanaのwallet事件など、ブロックチェーン業界はこのベアマーケットの中で不穏な日々が続いております。

それはさておき、本題に入ります。

背景としては、弊社が今年3月にJPYCv1をJPYCv2にアップデートした開発においては、upgradeableなコントラクトにしたいというニーズがあって、そのため当初はUpgradeabilityパターンについて色々と調査していました。せっかくなので記事としてまとめて共有しておきたいと思いました。ご参考になれば幸いです。

本記事は前回のスマコン開発Upgradeabilityの概要の続編ですので、よろしければそちらもご参照ください!今回はProxy Patternの特徴や注意点についてご説明します。

導入

ブロックチェーンベースのスマートコントラクトは非中央集権性、非改ざん性については非常に優れています。

一方で、改善・バグ修正ができないというデメリットがあります。以前、15万ETHが盗まれたParity Hackのような事件があったときには開発者はハッカーより先に資産を取り出す以外何もできませんでした。こういうときにupgradeableなコントラクトであればという思いが出てくるかと思います。

web2.0の開発ですと当たり前のようにできたバグの修正・プロダクトの改善がスマートコントラクトでもしやすくなるように、昨今では非中央集権性、非改ざん性の思想に反するようなupgradeabilityが議論されてきています。賛否両論はあるものの、今現状はそのバランスが取れた形でのupgradeableなコントラクトが多用されるようになって落ち着いているように見えます。その中でよく出てくるものとしてProxy Patternがあります。特にOpenZeppelinでも紹介されているTransparent Proxyがあります。

前提知識

Proxy Pattern


以前の記事でも書いたとおり、Proxy Patternに関してはストレージ、ロジックをそれぞれ分離した形でProxy、Implementationに分けていることとなります。proxy(データを保存するコントラクト)はimplementation(ロジックを保存するコントラクト)にあるロジックを呼び出してコードを実行しています。

Proxy Patternの特徴や注意点

本記事はそもそもProxy Patternがこれまでどういった特徴や注意点があったのかについて見てみます。

関数の衝突

以前のProxy Patterにおいては関数の衝突という課題がありました。こちらの課題の概要についてご説明します。

まず以下について理解する必要があります。

[In the Ethereum Virtual Machine] each function is identified by the first 4 bytes of its Keccak-256 hash. By placing the function’s name and what arguments it takes into a keccak256 hash function, we can deduce its function identifier.

Andreas Antonopoulos’ “Mastering Ethereum”にかかれている通り、EVMの関数はそれぞれKeccak-256ハッシュ(32bytes)値先頭の4bytes(以下ハッシュ値という)により確定されています。solidityのコントラクトが単独な場合、コンパイラーのエラーでチェックされるので同じハッシュ値になることはないのですが、Proxy Patternになってくるとコントラクトを経由しているので複数のコントラクト間のやり取りとなり、ハッシュ値が衝突することが発生しうるのです。

実際の関数の衝突を悪用した事例として以下のコードを御覧ください。

こちらのコントラクトはProxy contractになります。実際のプロダクションのシーンでは、ユーザーはこちらのProxy contractを経由してImplementationのコントラクトを呼び出すこととなります。

pragma solidity ^0.5.0;

// Proxy contract
contract Proxy {
		
// ------ ここからは正常なpart
    address public proxyOwner;
    address public implementation;

    constructor(address implementation) public {
        proxyOwner = msg.sender;
        _setImplementation(implementation);
    }

    modifier onlyProxyOwner() {
        require(msg.sender == proxyOwner);
        _;
    }

    function upgrade(address implementation) external onlyProxyOwner {
        _setImplementation(implementation);
    }

    function _setImplementation(address imp) private {
        implementation = imp;
    }

    function () payable external {
        address impl = implementation;

        assembly {
            calldatacopy(0, 0, calldatasize)
            let result := delegatecall(gas, impl, 0, calldatasize, 0, 0)
            returndatacopy(0, 0, returndatasize)

            switch result
            case 0 { revert(0, returndatasize) }
            default { return(0, returndatasize) }
        }
    }
// ------ ここまでは正常なpart
		
// これは悪意のある関数
    function collate_propagate_storage(bytes16) external {
        implementation.delegatecall(abi.encodeWithSignature(
            "transfer(address,uint256)", proxyOwner, 1000
        ));
    }
}

こちらはImplementationコントラクト(ERC20のコントラクト)とします。

import "openzeppelin-eth/contracts/token/ERC20/ERC20Burnable.sol";
import "openzeppelin-eth/contracts/token/ERC20/ERC20Detailed.sol";
import "zos-lib/contracts/Initializable.sol";

// バーン機能付きのとあるERC20 contract
contract BurnableToken is Initializable, ERC20Burnable, ERC20Detailed {

    function initialize(
        string memory name,
        string memory symbol,
        uint8 decimals,
        uint256 initialSupply
    ) 
        public 
        initializer
    {
        super.initialize(name, symbol, decimals);
        _mint(msg.sender, initialSupply);
    }
}

コントラクトに関してburn関数及びcollate_propagate_storage関数は同じ4bytesのハッシュ値0x42966c68を持っています。

そうすると、こちらのコントラクトを使ってユーザーがもしburn関数を呼び出すとなると、実はEVMの呼び出す順番としては最初にproxy contractの中で0x42966c68がないか確認をするのでその時に衝突されている関数collate_propagate_storageが呼び出されてImplementation contractのburn関数を呼び出すことはないのです。

collate_propagate_storageの中身を読むとわかりますが、tokenをburnするのではなく、imlementation側のtransfer関数を呼び出して、オーナーへトークンを送るというユーザーが予期しない行為となっています。実際にはもっと悪意のあるコードにしようと思えばできますし、これで脆弱性のポイントについてご理解いただけたかと思います。この脆弱性は誰が悪用するかはともかくとして、予期しない結果になること自体が非常に良くないとの認識です。

  • Transparent PatternはこれについてifAdminの修飾子をつけてコントラクトのやり取りにおけるユーザーと管理者の導線を限定することで関数の衝突を避けている
  • UUPS Patternについてはupgradeのロジックを含めてProxy内に置かず、Implementation側に置くようにしているので、衝突することもないと思われる

状態変数(State Variable)衝突

Proxy Patternを考案するにあたって、state variableの衝突は1つの課題でした。

実際にProxy PatternですとImplementation contractを通してproxy contractのstate variableをいじることになるので、その中でロジックのほうできちんとproxyのstate variable意識して構成することが必要です。そうでないと、以下のようなことが起こり、state variableの不一致が起きてしまいます。これがいわゆるstate variableの衝突(Collision)といいます。

state variableの衝突を理解するには、以下の実例を見てみましょう。remixで試してもらっても良いと思います。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// implementation contract: LostStorage
contract LostStorage { 
    // 衝突する状態変数の宣言
        address public collisionAddress; 
    uint public someUint;

// 以下はsetter関数で、あまり気にしなくてよい
    function setAddress(address _address) public {
        collisionAddress = _address;
    }

    function setMyUint(uint _uint) public {
        someUint = _uint;
    }

}

//proxy contract: ProxyClash
contract ProxyClash { 
    address public aotherContractAddress;
    uint public anotherUint;
    
// proxy contractのconstructor、引数にimplementation contract addressと任意の数字を代入
    constructor(address _aotherContract, uint _num) {
        aotherContractAddress = _aotherContract;
        anotherUint = _num;
    }

// setter関数で、あまり気にしなくてよい
    function setOtherAddress(address _aotherContract) public {
        aotherContractAddress = _aotherContract;
    }

// proxy contractのfallback関数でcallをimplementation contractへdelegatecallするための内容が書かれている
  fallback() external {
    address _impl = aotherContractAddress;

    assembly {
      let ptr := mload(0x40)
      calldatacopy(ptr, 0, calldatasize())
      let result := delegatecall(gas(), _impl, ptr, calldatasize(), 0, 0)
      let size := returndatasize()
      returndatacopy(ptr, 0, size)

      switch result
      case 0 { revert(ptr, size) }
      default { return(ptr, size) }
    }
  }
}

コードをremixへコピペしてデプロイしていきます

  1. まずはLostStorageをデプロイします
  2. ProxyClashをデプロイします(constructorの引数にデプロイしたLostStorageのアドレス、任意の数字(例:255)を入れる)
  3. デプロイしたProxyClashのアドレスをAt Addressに記載し、LostStorageを選択してAt Addressボタンをクリックしデプロイする

4. 最後にデプロイしたコントラクトのcollisionAddress, someUintを呼び出す

最後にcollisionAddress, someUintにはすでに値が入っていることがわかります。これがstate variableの衝突です。

Transparent PatterやUUPS Patternはこの課題を解決できています。

State Variableの衝突を避ける手法

具体的にはどのような方法を使ってstate variablesの衝突を避けているかというと、Proxy PatternはUnstructured Storageを使っています。

Unstructured Storageとは何か?

Solidity’s storage layout can be bypassed with assembly allowing a programmer to set and store values at arbitrary positions in contract storage. This is the Unstructured Storage pattern.
Solidityのストレージlayoutはassemblyを使うことでバイパスすることができる。assemblyでやることは任意の位置に値をsetしそして保存することである。これがUnstructured Storage patternという。

Unstructured Storageのデメリット:①各々のstate variableについてgetter, setterの関数の定義が必要、②簡単な値は設定可能だが、structsあるいはmappingは機能しない。

Unstructured Storage

proxy contractが唯一の変数_implementationを保存しているとします。logic contractが基本的なERC20のcontractだとし、addressの_ownerが変数として最初に存在するとします。両方とも32byteのストレージに保存されている。では、logic contractが_ownerへ書き込みを実行するときに、delegatecallを使うので、proxyのスコープ内でcallが実行されます。実際にはproxyにある_implementationを上書きしてしまう。この問題は「ストレージの衝突」といい、上記のstate variableの衝突と似たような状況です。

https://img.esa.io/uploads/production/attachments/17522/2021/12/28/104568/7e9d03a5-57e3-4ef5-8c0c-8bc3c9107a7d.png

この問題を避けるために、いくつかの方法があります。1つとしてあるのはunstructured storageです。_implementationのアドレスをproxyの最初のストレージに保存するのではなく、ランダムに選んだslotに保存します。このslot位置が十分にランダム性を持てばlogic contractとの衝突は免れるという概念が元になっています。proxyに他の変数が必要となった場合、例えばadminのaddress等は同じ方法を使ってこの「ストレージの衝突」を避けられます。

https://img.esa.io/uploads/production/attachments/17522/2021/12/28/104568/135e49ca-1aab-4d76-a078-9781e5fb858a.png

ソリューションの詳細についてはEIP1967を参照されたいです。このソリューションによって、logic contractがproxyの変数を不意に上書きしてしまうことを気にされなくて良くなりました。これまでだと、logic contract側はproxyのストレージストラクチャを知った上で適応させる必要がありました。これが「unstructured storage」の由来です。これによってImplementation contractまたはproxy contractはお互いのデータ構造を気にされなくてもよいということになりました。

constructor関数に変わるinitialize関数

Proxy Patternの場合ですとImplementation Contract、Proxy contractに分けるため、Implementation Contractにあるconstrutor関数は意味をなさなくなります。なぜなら、Implementation ContractはロジックだけをProxy側へ提供しているので、自分自身のconstructorはあってもProxy側からすると何も影響されないのは明確です。従ってinitializer継承したinitiallize関数の設定が必要となります。

OpenZeppelinからとってきた一例は下のコードです。こちらはプロダクションのコードと多少異なりますが、原理は一緒です。

ただし、initialize関数はconstructorみたいに最初から自動的に呼ばれることはないので、手動で呼ぶ必要が出てきます。

  • initialize関数をconstructorの代わりに設置
  • 一度しか呼べないようにinitializedというboolを設置
// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract MyContract {
    uint256 public x;
    bool private initialized;

    // constructorのかわりにinitialize関数を設定
    function initialize(uint256 _x) public {
    // 一度しか呼べないようにinitializedというboolを設定
        require(!initialized, "Contract instance has already been initialized"); 
        initialized = true;
        x = _x;
    }
}

ここでのInitializableは一度しか呼ばれないようにするためのOpenZeppelinのコントラクトです。

// contracts/MyContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";

contract MyContract is Initializable {
    uint256 public x;

    function initialize(uint256 _x) public initializer {
        x = _x;
    }
}

その他諸々

Implementation側のinitialize関数

また、implementation側のinitialize関数に関しては、proxy contractへの影響があまりないとはいえ、誰か知らない他人に呼び出されてオーナー権が乗っ取られるのも嫌ですし、攻撃者がImplementation contractのオーナー権をもっていれば何かとリスクは潜んでいるかもしれないので、implementation側のinitialize関数は予め呼び出してあるいは、呼び出せないように、bool値を変更して上げたほうがよいとされています。

OpenZeppelin Library

結論からいうと、OpenZeppelinは便利なupgradeableなライブラリを用意してくれているので、特別な理由が無い限り使ったほうがよいかと思います。前述した通り、upgradeableなコントラクトになるとconstructorが不要になるなど、変更点があるので、こういったスタンダードなコントラクトが従来のものとは別に作成されていたと思われます。

初期値設定を使用しない

solidityにおいては初期値を付与するには、下記のような書き方が許されることはご存知だと思います。

contract MyContract {
    uint256 public hasInitialValue = 42; // equivalent to setting in the constructor
}

これはconstructorに初期値を設定したと実質同じものですので、constructorはupgradeableなコントラクトでは使われないので、避けたほうがよいです。

delegatecallやselfdestructは使わない

upgradeableなコントラクトを使う際にImplementation側のコントラクトとのインタラクションはしません。しかし、ブロックチェーン上にあるものなので、攻撃者がトランザクションをImplementationへ送ることを止めることはできません。

通常の場合はこれは影響がないでしょうが、例外としてあるのはdelegatecallがImplementation側にある場合です。この場合、攻撃者はdelegatecallして外部にあるselfdestruct関数を呼び出してImplementation contractを消失させることが可能になります。Proxy側からするとImplementationが存在しないこととなり、使えないものになってしまいます。また、もちろんselfdestructそのものをImplementation contractに書くということもリスクとなります。

従って、selfdestructあるいはdelegatecallをImplementation contractに追加することはおすすめできません。

継承したコントラクトと状態変数(state variable)の衝突

upgradeするときに注意することについて書きます。

// 既存
contract A {
    uint256 a;
}


contract B {
    uint256 b;
}


contract MyContract is A, B {}
// upgrade
contract MyContract is B, A {}

上記のように、継承したコントラクトの順番は変えると状態変数も逆転するのでかえないこと

// 既存
contract Base {
    uint256 base1;
}


contract Child is Base {
    uint256 child;
}
// upgrade
contract Base {
    uint256 base1;
    uint256 base2;
}

上記の通り、状態変数を追加するとchildと衝突してしまいます。迂回する方法としては、デプロイする時点に予めBaseコントラクトにおいて空っぽの関数を宣言しておくことです。これがOpenZeppelinのコントラクトにあるgapが使われている理由です。

         /**
     * @dev This empty reserved space is put in place to allow future versions to add new
     * variables without shifting down storage in the inheritance chain.
     * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
     */
    uint256[45] private __gap;
}

まとめ

以上はJPYCv2の開発において自分の調べてきたProxy Patterに関わる特徴や注意点についてご紹介しました。Proxy Patternを調べていくにあたって必ず出てくる内容ですので、こういった点を抑えた上で開発を進めていったほうがよいと考えています。特にupgradeabilityの実装については従来の方法に比べて複雑であるため、色々気を配ったほうがよいです。

Proxy Patternを使う際には意識していただけたら幸いだと思います。いかがでしょうか。

これからもブロックチェーン等の開発に役に立つ技術的な知見を記事にしていきますのでよろしくお願いします!

日本初のブロックチェーン技術(ERC20)を活用した日本円ステーブルコインJPYCはこちらから購入できます!JPYC社はブロックチェーンエンジニアを募集中です!こちらからご応募お願いします!(タイミングにより募集を行なっていない場合があります)また、ラボ形式でブロックチェーンに関する講義をしているJPYC開発コミュニティにも是非ご参加ください!

免責事項:記事内にかかれているコードはあくまで原理や知識を表現するために使われているので、プロダクション環境での使用はご自身のリスクや状況に沿って判断を行ってください。

Discussion

ログインするとコメントできます