Skip to content

Latest commit

 

History

History

storage-collision

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

Storage Collisions & Proxies

目次

Storage Collisionとは

Storage Collision(ストレージの衝突)とは、あるコントラクトとdelegatecall先のコントラクトの使用するストレージのスロットが同じである(衝突している)ことを言います。 Storage Collisionが起こると、そのストレージの値が意図しない値に書き換わってしまうことがあります。

Storage Collisionはdelegatecallを使用するコントラクトで起こり得ますが、delegatecallはプロキシと呼ばれる技術で特に使われているので、プロキシについて説明します。

プロキシとは

まず、あるアドレスに一度デプロイしたバイトコードは一部の例外を除き変更することはできません。 例外は、CREATE2命令でデプロイされたコントラクトを、SELFDESTRUCT命令で破壊して、再度CREATE2命令でデプロイした場合のみです(そして、ストレージのデータはリセットされます)。

プロキシとは、主にコントラクトのアップグレード(機能の追加や削除など)を擬似的に行うために利用される手法です。 プロキシを使うデザインパターンをプロキシパターンと呼びます。

プロキシパターンでは、アプリケーションを2つのコントラクトに分けます。 アプリケーションの実装を担当するロジックコントラクトと、そのロジックコントラクトに機能を委譲するプロキシコントラクトです。

基本的なイメージは以下です。

sequenceDiagram
    participant User
    participant Proxy
    participant Logic

	User ->>+ Proxy: call
	Proxy ->>+ Logic: delegatecall
	Note over Logic: 任意の処理
	Logic ->>- Proxy: (delegatecall終了)
	Proxy ->>- User: (call終了)
Loading

Proxyコントラクトが、アプリケーションのロジックを持つLogicコントラクトにdelegatecallを行っていますが、このdelegatecallで呼び出す参照先を変えることで擬似的にアップグレードを実現しているということです。

プロキシにおけるStorage Collision

プロキシのStorage Collisionの例

実際のStorage Collisionの例として、プロキシの間違った実装を見てみましょう。

以下は、アップグレード可能なCounterコントラクトの失敗例です。

contract Counter {
    uint256 public number;

    function setNumber(uint256 newNumber) public {
        number = newNumber;
    }

    function increment() public {
        number++;
    }
}

contract BadProxy {
    address owner;
    address implAddr;

    constructor(address implAddr_) {
        owner = msg.sender;
        implAddr = implAddr_;
    }

    function upgradeTo(address newImplAddr) public {
        require(owner == msg.sender);
        implAddr = newImplAddr;
    }

    fallback(bytes calldata input) external returns (bytes memory) {
        (bool success, bytes memory res) = implAddr.delegatecall(input);
        if (success) {
            return res;
        } else {
            revert(string(res));
        }
    }
}

Counter counter = Counter(address(new BadProxy(address(new Counter()))));のようしてセットアップします。

BadProxyコントラクトのコンストラクタで、ロジックコントラクトのアドレスをセットし、ownermsg.senderになります。 upgradeTo関数で、ロジックコントラクトをアップグレードできますが、ownerしか実行できません。

そして、fallback関数でdelegatecallをしています。 setNumberincrementnumberを呼び出すと全てこのfallback関数を経由して、ロジックコントラクトにdelegatecallを行います。

ここでsetNumber(0)を実行するとどうなるでしょうか?

まず、BadProxyコントラクトのfallback関数を経由して、delegatecallCounterコントラクトのsetNumber(0)が呼ばれます。 setNumber関数では、number = 0が実行されます。

しかし、このnumberの実態はスロット0で、setNumber関数は実質スロット0を書き換えるだけに過ぎません。 BadProxyにおいてスロット0はownerです。 そのため、owner0に書き換わります。 以下は(BadProxyの)コントラクタ実行後のストレージのレイアウトです。

slot 初期値 BadProxyでの識別名 Counterでの識別名
0 コンストラクタでのmsg.sender owner number <- setNumber(x)を実行するととslot 0がxに書き換わる
1 コンストラクタでのimplAddr_ implAddr

結果的に、setNumber(0)を実行した後で、number()を実行すると0が返ってきますが、owner()0と返ってくる状態になります。 そして、ownerが書き換わったことで、アップグレードができなくなってしまいます。

また、攻撃者がsetNumber(attackerAddress)を実行すれば、ownerが攻撃者のアドレスに書き換わり、コントラクトを乗っ取ることが可能です。

演習: コントラクトの乗っ取り

上記の例において、CounterコントラクトのsetNumber関数で設定できるnewNumberに制限を設けました。 newNumber < type(uint256).max / 2を満たさないといけません。 その上で、numbertype(uint256).maxにしてください。

以下のコマンドを実行して、テストがパスしたらクリアです。

forge test --match-path course/storage-collision/challenge-bad-proxy/Challenge.t.sol -vvv

正しい実装例

Storage Collisionの原因は、プロキシコントラクトとロジックコントラクトで同じスロットを使ってしまうことです。

つまり、Storage Collisionを避けるには、単純にプロキシコントラクトとロジックコントラクトで異なるスロットを使えば良いということです。

例えば、以下のようなストレージのレイアウトにするとStorage Collisionは起こりません。

slot 初期値 BadProxyでの識別名 Counterでの識別名
0 コンストラクタでのmsg.sender owner
1 コンストラクタでのimplAddr_ implAddr
2 0 number

これは以下のように修正することで実現できます。

contract ProxyStorage {
    address public owner;
    address public implAddr;
}

contract Counter is ProxyStorage {
    uint256 public number;

    function setNumber(uint256 newNumber) public {
        number = newNumber;
    }

    function increment() public {
        number++;
    }
}

contract Proxy is ProxyStorage {
    constructor(address implAddr_) {
        owner = msg.sender;
        implAddr = implAddr_;
    }

    function upgradeTo(address newImplAddr) public {
        require(owner == msg.sender);
        implAddr = newImplAddr;
    }

    fallback(bytes calldata input) external returns (bytes memory) {
        (bool success, bytes memory res) = implAddr.delegatecall(input);
        if (success) {
            return res;
        } else {
            revert(string(res));
        }
    }
}

Proxyコントラクトのストレージ部分をProxyStorageコントラクトに切り出して、ロジックコントラクトであるCounterコントラクトにも継承させています。

アップグレーダビリティの是非

そもそも、Storage Collisionが起こる原因はこのようなアップグレードの機能などを実装するためにプロキシコントラクトを利用することにあります。 Storage Collisionが起こるリスクを負ってまで、アップグレーダビリティを実装すべきかどうかは、様々な議論が行われています。

アップグレーダビリティが求められている背景に、コントラクトが長期間利用されればされるほど、ロジックの変更やバグの修正が必要になる可能性が高まる点が挙げられます。 アップグレーダビリティが無ければ、そのような変更を行うことは難しいですし、新たなコントラクトに移行するコストは高くなるでしょう。 特に以前のコントラクトの状態をそのまま引き継ぐことが難しいというのが問題になります。

一方で、アップグレーダビリティはStorage Collisionを生み出す可能性がある他に、ガバナンスのモデル設計が必要という点もあります。 不適切な意思決定によってコントラクトが変更されないためにも、コンセンサスに至るメカニズムを慎重に決めなければなりません。

ちなみに、Curve Financeはアップグレーダビリティに強く反対しておりアップグレーダビリティがありません。

Just say no to the combined evils of proxies, upgradability, slot collisions and mutability! Not only these features create enormous complexity, they also encourage writing something vulnerable to cefi regulations. https://twitter.com/CurveFinance/status/1551877828580343809

さらに「アップグレーダビリティはバグだ」という思想もあります。 これはCurve Finance含め色んな人が言っています。

実例: AudiusのStorage Collisionに起因する任意コード実行

2022年7月23日に、AudiusがStorage Collisionが原因で攻撃されました(post-mortem)。 (seccamp 2022では、参加者がこの攻撃を解析して2パターンの攻撃をまとめました。)

ここではその概要を説明します。

様々なコントラクトの継承とプロキシが絡み合い少々複雑なのですが、根本的な原因は、次の2つのストレージスロットが衝突していたことにあります。

  • AudiusAdminUpgradeabilityProxyコントラクトのProxyAdmin
  • Governanceコントラクトのinitializedinitializing

イメージとして、seccamp 2022の参加者が作成した図を引用します。

proxyAdmin = 4d ec a5 17 ...  30 03 ab ac
                                    │  │
                                    │  └─ initialized (true)
                                    └── initializing (true)

initializedinitializingはどちらもboolであり、それらがProxyAdminの下位2バイトに対応しています。 下位2バイトはどちらも0ではないので両方ともtrueになっていました。

initializedinitializingは、元を辿ればOpenZeppelinのライブラリにあるInitializableコントラクトにあります。 Initializableコントラクトはコンストラクタとは別に初期化処理を追加するための処理をまとめたものです。

なぜコンストラクタがあるのにInitializableコントラクトが必要かというと、ロジックコントラクトのコンストラクタはプロキシコントラクトのコンテキストで実行されないからです。 プロキシコントラクトからロジックコントラクトの初期化関数を呼び出すことで擬似的にロジックコントラクトのコンストラクタで行うべき処理を実行しているイメージです。

そして、initializedinitializingはその名の通り、それぞれ初期化したか初期化中かを記録するためのフラグです。

Initializableパターンでは、次のinitializerモディファイアをつけた関数を初期化関数としていました。

  modifier initializer() {
    require(initializing || isConstructor() || !initialized, "Contract instance has already been initialized");

    bool isTopLevelCall = !initializing;
    if (isTopLevelCall) {
      initializing = true;
      initialized = true;
    }

    _;

    if (isTopLevelCall) {
      initializing = false;
    }
  }

さて、ここでinitializinginitializedtrueだった場合を考えてみます。

最初のrequireの条件であるinitializing || isConstructor() || !initializedは、true || isConstructor() || falseとなります。 これはtrueなので、常に条件をパスします。 つまり、initializerモディファイアを修飾した初期化関数が再度実行可能だったということです。

initializerモディファイアはGovernanceコントラクトのinitialize関数に修飾されていました。

    function initialize(
        address _registryAddress,
        uint256 _votingPeriod,
        uint256 _executionDelay,
        uint256 _votingQuorumPercent,
        uint16 _maxInProgressProposals,
        address _guardianAddress
    ) public initializer {
        require(_registryAddress != address(0x00), ERROR_INVALID_REGISTRY);
        registry = Registry(_registryAddress);

        require(_votingPeriod > 0, ERROR_INVALID_VOTING_PERIOD);
        votingPeriod = _votingPeriod;

        // executionDelay does not have to be non-zero
        executionDelay = _executionDelay;

        require(
            _maxInProgressProposals > 0,
            "Governance: Requires non-zero _maxInProgressProposals"
        );
        maxInProgressProposals = _maxInProgressProposals;

        require(
            _votingQuorumPercent > 0 && _votingQuorumPercent <= 100,
            ERROR_INVALID_VOTING_QUORUM
        );
        votingQuorumPercent = _votingQuorumPercent;

        require(
            _guardianAddress != address(0x00),
            "Governance: Requires non-zero _guardianAddress"
        );
        guardianAddress = _guardianAddress;  //Guardian address becomes the only party

        InitializableV2.initialize();
    }

この関数を呼び出し、_registryAddress_guardianAddressのアドレスを自身の攻撃コントラクトのアドレスに設定し、他の引数も適切に設定することで、即時にAudiusのトークンを全て奪取することが可能でした。 また、奪ったトークンは奪ったと同時にUniswapで約680 etherにスワップできました。

ちなみに実際の攻撃者は、即時にトークンを奪えることに気づかずに、ガバナンスでのプロポーザルを経由して無駄に複雑な攻撃をしています。

以下、参考までにseccamp 2022の参加者の実装です。