【入門編】 乱数生成の予測可能性とChainlink VRFの活用 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

こんにちは!セキュリティの世界へようこそ。

今回は、近年急速に発展している「Web3・ブロックチェーン」の世界における、非常に重要で、かつ多くの開発者が一度は頭を悩ませるテーマ「乱数(ランダムな値)の作り方」についてお話しします。

「ランダムな値なんて、サイコロを振るプログラムを書けば一瞬でできるのでは?」と思うかもしれません。しかし、ブロックチェーンの世界では、この「サイコロを振る」という行為が、実は泥棒に手の内をすべて見せているのと同じ状態になってしまうことがあるのです。

この記事では、スマートコントラクト(自動で実行される契約プログラム)における「予測可能な乱数」の危険性を、お家の防犯や泥棒の心理に例えながら、一歩ずつ丁寧に紐解いていきます。最後には、安全に乱数を作るための標準的な仕組みである「Chainlink VRF」を使った具体的な対策コードも紹介します。

難しそうに見えるセキュリティの世界ですが、基本から順番に進めば大丈夫です。一緒に学んでいきましょう!

—

1. なぜブロックチェーンで「ランダム」は難しいの?

まず、身近な「防犯」の例から考えてみましょう。

あなたがお家の玄関に「毎日自動で変わる暗証番号式の鍵」を取り付けたとします。この暗証番号が、もし「今日の天気」や「曜日」といった、誰でも調べればすぐに分かるルールで決められていたらどうでしょうか?

近所の泥棒は、わざわざ鍵を壊さなくても、「今日は月曜日で雨だから、暗証番号は『1234』だな」と簡単に予測して、堂々とドアを開けて入ってきてしまいますよね。

ブロックチェーン(特にイーサリアムなど)の世界でも、これと全く同じことが起こります。

全員に中身が見えている「ガラス張りの世界」

ブロックチェーンの最大の特徴は、「すべてのデータやプログラムの実行過程が、誰にでも丸見え(オープン)」であることです。

一般的なパソコンのプログラムであれば、サーバーの奥深く(見えない場所)でこっそりサイコロを振ることができます。しかし、ブロックチェーン上のスマートコントラクトは、世界中のコンピューター(ノード)が同じプログラムを動かして「確かにこの結果(データ)で合っているね」と確認し合う仕組みです。

そのため、プログラムの中で「適当な数字を作ってね」と命令しても、その計算プロセスが全員に見えてしまっているため、「これからどんな数字が作られるか」を事前に計算して先回りできてしまうのです。

—

2. 泥棒に先回りされる「危険な乱数の作り方」

では、具体的にどのようなプログラムが「泥棒に予測されてしまう暗証番号」になってしまうのでしょうか。

スマートコントラクト(Solidity言語)でよく初心者が書いてしまいがちな、絶対にやってはいけないNGコードを見てみましょう。

危険なコードの例(ブロックハッシュ依存)

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

// ⚠️ 注意:このコードは脆弱性(弱点)があるため、本番環境で使用してはいけません!
contract DangerousRandomness {
    
    // 参加者がお金を賭けて、運が良ければ景品がもらえる簡易ゲーム
    function playGacha() public payable {
        require(msg.value == 0.01 ether, "0.01 ETHを支払ってください");

        // ❌ 【超危険】現在のブロックのハッシュ値やタイムスタンプを使って乱数を作っている
        uint256 dangerousRandom = uint256(
            keccak256(
                abi.encodePacked(
                    blockhash(block.number - 1), // 1つ前のブロックのハッシュ値
                    block.timestamp,             // 現在の時刻
                    msg.sender                   // プレイヤーのアドレス
                )
            )
        );

        // 10で割った余りが「7」なら大当たり!
        if (dangerousRandom % 10 == 7) {
            // 大当たりの処理(景品を送るなど)
            payable(msg.sender).transfer(address(this).balance);
        }
    }

    // コントラクトにお金を受け入れるための設定
    receive() external payable {}
}

なぜこれが危険なのか?(泥棒の視点)

このコードでは、乱数を作るために blockhash(1つ前の取引データの塊が持つ固有の番号)や block.timestamp(現在の時刻)を使用しています。

一見すると、毎回違う値が入るためランダムに見えます。しかし、攻撃者(泥棒)は以下のような「ずる賢い攻撃用コントラクト」を作って挑戦してきます。

1. 攻撃者は、自分で作ったプログラム(コントラクト)経由で、上記の playGacha を呼び出します。
2. 攻撃者のプログラムの中で、上記と全く同じ計算(blockhash や block.timestamp を使った計算)を事前に実行します。
3. 計算結果が「ハズレ」だと分かったら、その瞬間に取引全体をキャンセル(リバート)します。
4. 計算結果が「アタリ」の時だけ、取引をそのまま実行してお金を受け取ります。

これでは、攻撃者は「絶対にアタリが出る時しかゲームに参加しない」という、100%勝てる状態になってしまいます。ゲームを運営している側からすると、金庫の中身がすべて盗まれてしまう大惨事です。

—

3. 救世主「Chainlink VRF」の登場!

この「誰にでも先回りされてしまう問題」を解決するために開発されたのが、Chainlink VRF(Verifiable Random Function:検証可能なランダム関数)という仕組みです。

これを防犯の仕組みに例えると、「絶対に不正ができない、防犯カメラ付きの厳重な抽選会場」を外に作るようなものです。

Chainlink VRF の仕組み

1. 抽選の依頼: あなたのスマートコントラクトが、ブロックチェーンの外にいる「信頼できる第三者(Chainlinkのオラクル)」に「ランダムな数字を1つ作って!」と依頼します。
2. 検証可能な乱数の生成: Chainlinkは、コンピューターの内部だけで乱数を作るのではなく、「暗号学的な証明書」と一緒に乱数を作ります。
3. オンチェーンでの検証: 届いた乱数と証明書が、本当に不正なく作られたものかどうかを、スマートコントラクトが自動で数学的に検証します。
4. 結果の受け取り: 検証が「合格」となって初めて、安全な乱数としてゲームなどに使用されます。

この仕組みのおかげで、攻撃者はおろか、乱数を提供しているChainlink自体であっても、結果を操作したり事前に予測したりすることができなくなります。

—

4. 【実践】Chainlink VRFを使って安全なコードを書こう!

それでは、実際に Chainlink VRF(最新の v2.5 に対応した形)を使って、安全に乱数を受け取るスマートコントラクトのテンプレートを見てみましょう。

初心者の方でも分かりやすいよう、コメントで丁寧に解説を入れました。

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

// Chainlinkが提供する安全なコントラクトをインポートします
import "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import "@chainlink/contracts/src/v0.8/vrf/namespaces/VRFV2PlusClient.sol";

/**
 * @title 安全なガチャコントラクト
 * @dev Chainlink VRFを利用して、予測不可能な乱数を取得します
 */
contract SafeGacha is VRFConsumerBaseV2Plus {
    
    // --- 【設定項目】これらは利用するネットワーク(Sepoliaテストネット等)に合わせて設定します ---
    uint256 public s_subscriptionId; // Chainlinkに支払う手数料のサブスクリプションID
    bytes32 public keyHash;          // 乱数生成の速さやガス代の上限を決めるキーハッシュ
    uint32 public callbackGasLimit = 100000; // 乱数が戻ってきた時に実行する処理のガス代上限
    uint16 public requestConfirmations = 3;  // 安全のために何ブロック待つか(通常は3)
    uint32 public numWords = 1;      // 欲しい乱数の個数(今回は1個)

    // 乱数のリクエストIDと、それを求めたプレイヤーを紐付ける記録簿
    mapping(uint256 => address) public s_rollers;
    // プレイヤーごとのゲーム結果(アタリかハズレか)を記録する
    mapping(address => string) public gameResults;

    // 乱数リクエストが送信されたことを知らせるイベント(ログ)
    event DiceRolled(uint256 indexed requestId, address indexed roller);
    // 乱数が無事に届いて結果が確定したことを知らせるイベント
    event DiceLanded(uint256 indexed requestId, uint256 result);

    /**
     * @dev コンストラクタ(初期設定)
     * @param subscriptionId Chainlink VRFのサブスクリプションID
     * @param vrfCoordinator VRFを管理する親コントラクトのアドレス
     * @param _keyHash ネットワークごとのキーハッシュ
     */
    constructor(
        uint256 subscriptionId,
        address vrfCoordinator,
        bytes32 _keyHash
    ) VRFConsumerBaseV2Plus(vrfCoordinator) {
        s_subscriptionId = subscriptionId;
        keyHash = _keyHash;
    }

    /**
     * @notice ゲームに参加する関数(乱数のリクエストを開始します)
     */
    function playGacha() external payable returns (uint256 requestId) {
        // 最低限の参加費(例: 0.001 ETH)をチェック
        require(msg.value >= 0.001 ether, "Not enough ETH to play");

        // Chainlink VRFへのリクエストパラメータを組み立てます
        requestId = s_vrfCoordinator.requestRandomWords(
            VRFV2PlusClient.RandomWordsRequest({
                keyHash: keyHash,
                subId: s_subscriptionId,
                requestConfirmations: requestConfirmations,
                callbackGasLimit: callbackGasLimit,
                numWords: numWords,
                extraArgs: VRFV2PlusClient._argsToBytes(
                    VRFV2PlusClient.ExtraArgsV1({nativePayment: false}) // LINKトークンで手数料を支払う設定
                )
            })
        );

        // 誰がこのリクエスト(抽選)を投げたかを記録しておく
        s_rollers[requestId] = msg.sender;
        gameResults[msg.sender] = "抽選中...";

        emit DiceRolled(requestId, msg.sender);
    }

    /**
     * @notice Chainlinkから安全な乱数が戻ってきた時に、自動で呼び出される関数
     * @dev この関数はChainlinkのシステム(Coordinator)だけが実行できるように制限されています
     */
    function fulfillRandomWords(
        uint256 requestId,
        uint256[] calldata randomWords
    ) internal override {
        // リクエストIDから、対応するプレイヤーのアドレスを特定します
        address roller = s_rollers[requestId];
        
        // 届いた超巨大な乱数(randomWords[0])を、10で割った余りに変換(0〜9のいずれかになります)
        uint256 gachaResult = (randomWords[0] % 10) + 1;

        // 結果の判定(例:7が出たら「大当たり!」、それ以外は「ハズレ」)
        if (gachaResult == 7) {
            gameResults[roller] = "★大当たり!おめでとうございます!★";
        } else {
            gameResults[roller] = "残念、ハズレです。また挑戦してね!";
        }

        emit DiceLanded(requestId, gachaResult);
    }
}

このコードが安全な理由

1. 二段階のステップ(非同期処理): プレイヤーが「ガチャを回す」ボタンを押した瞬間には、まだ乱数は決まっていません。一度Chainlinkにリクエストを送り、数秒〜数十秒後に戻ってくるという「時間差」が発生します。これにより、攻撃者はその場で結果を見て「キャンセルする」というズルができなくなります。
2. 検証可能なプログラム: fulfillRandomWords という関数は、Chainlinkの公式システム(親コントラクト)からしか呼び出せないよう、裏側で強力な鍵(修飾子)によって保護されています。

—

5. まとめ:安全なブロックチェーン開発の第一歩

今回は、ブロックチェーン開発で必ず直面する「乱数生成の予測可能性」という弱点と、それを解決する「Chainlink VRF」の仕組みについて解説しました。

今日のポイントをもう一度おさらいしましょう!

  • ブロックチェーン上のデータはすべて丸見え。 だから、コントラクト内の情報(時刻や過去のブロックデータなど)だけで乱数を作ってはいけない。
  • 泥棒は「ハズレなら取引を無効にする」というズルができるため、簡単な乱数はすべて先回りされて破られてしまう。
  • Chainlink VRF を使えば、ブロックチェーンの外側から「数学的に不正がないことを証明された、安全な乱数」を連れてくることができる。

Web3やスマートコントラクトの開発は、これまでのWeb開発(Web2)とは防犯のセオリーが大きく異なります。しかし、今回のように「なぜ危険なのか?」という仕組みを理解し、標準的な解決策を一つずつ学んでいけば、決して怖いものではありません。

これからも安全で楽しいdApps(分散型アプリ)の開発を進めていきましょう!「一歩ずつ対策を学んでいきましょう!」

コメント

タイトルとURLをコピーしました