【テクニカル・上級編】 乱数生成の予測可能性(Weak Randomness) – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

ブロックチェーン上の「偽りの運命」:弱いが故に刈り取られるオンチェーン・エントロピーの現実

スマートコントラクトの監査を数十件とこなしてきた者であれば、開発者が「いかに手軽にランダムな値を手に入れたがっているか」を痛いほど知っている。NFTのミントでレアリティを決めたい、PvPのオンチェーンゲームでクリティカルヒットの判定を行いたい、あるいは暗号資産の抽選を公正に実行したい。その動機は健全だが、彼らが選ぶ手法の多くは、攻撃者にとって格好の「ATM」と化している。

ブロックチェーンは本質的に決定論的なステートマシン(状態機械)だ。世界中のバリデータが同じトランザクションを同じ順序で実行し、完全に一致する状態へと収束させなければならない。この厳格な決定論の世界において、「予測不可能な乱数」を生み出すこと自体が、パラドックスなのだ。

ここに踏み込む開発者は、往々にして block.timestamp や blockhash、さらには block.difficulty (現 block.prevrandao)といった、トランザクションコンテキスト内の変数を乱数源として採用する。だが、これらはセキュリティの世界では「乱数」ではなく、単に「マイナーやバリデータが意図的に操作可能、あるいは事前に計算可能な公開変数」に過ぎない。

今回は、この「弱小乱数(Weak Randomness)」のメカニズムが生む致命的な脆弱性に焦点を当て、攻撃者がどのようにブロックの裏をかいて利益を吸い上げているのか、そして我々セキュリティアーキテクトがどのようにしてこれを根絶すべきかを、泥臭い実装の裏側まで含めて解き明かしていこう。

—

脆弱性の根本原因:ブロックチェーンにおける決定論の罠

なぜ blockhash(block.number - 1) や block.timestamp を使ったコードは、実戦において秒速でハックされるのか。その根本原因は、「状態の生成と検証のタイムラグ、および情報非対称性の欠如」にある。

PoS(Proof of Stake)移行後のイーサリアムでは、block.prevrandao が導入され、ビーコンチェーンのRANDAOミキシングから導出された値が利用できるようになった。しかし、これとて「完全に予測不可能な外部エントロピー」ではない。バリデータは、自分が提案する予定のブロックにおいて、どのようなトランザクションをどの順序でパッキングするかをある程度コントロールできる。

もしスマートコントラクトのロジックが、そのトランザクションが実行される「まさにそのブロック」のハッシュやタイムスタンプに依存している場合、攻撃者は次のような手口(シミュレーションと条件付き実行)を使う。

1. ターゲットとなるコントラクトの「乱数を利用する関数」を呼び出すトランザクションを構築する。
2. ローカルのEVM(Ethereum Virtual Machine)環境で、そのブロックのコンテキスト(タイムスタンプやprevrandao)を偽装して事前にシミュレーションを実行する。
3. シミュレーション結果が「ハズレ(望まない結果)」であれば、トランザクションをミンプール(Mempool)にブロードキャストせず、破棄する。
4. 「当たり(大当たりのNFT、高額な報酬)」が出る条件を満たすブロックコンテキストが算出できる、あるいは自分でブロックを提案する立場(バリデータ)であれば、その瞬間を狙ってトランザクションを強制的にインジェクトする。

この攻撃手法は、DeFiのフラッシュローン(Flash Loan)や、ブロックの末尾を狙ったMEV(Maximal Extractable Value)ボットによって、ミリ秒単位で自動化されている。開発者が「誰もこんな予測できないだろう」と高を括っている間に、ボットは数学的な必然として勝利をぎりぎりまで確信しながらスマートコントラクトの資金を空にしているのだ。

—

脆弱な実装の解剖:何が間違っているのか

まずは、セキュリティレビューの現場で最も頻繁に発見される「アンチパターン」のコードを見てみよう。以下のコントラクトは、一見すると綺麗に書かれたオンチェーン・ガチャのスマートコントラクトだが、中身は致命的な欠陥を抱えている。

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

/**
 * @dev 【危険な実装例】絶対に本番環境で使用してはならない脆弱なガチャコントラクト
 * ブロックハッシュとタイムスタンプを乱数源にしているため、MEVボットに完全に予測・操作されます。
 */
contract InsecureGacha {
    address public owner;
    uint256 public price = 0.1 ether;

    event Rolled(address indexed player, uint256 result);

    constructor() {
        owner = msg.sender;
    }

    // 脆弱な乱数生成関数
    function roll() external payable {
        require(msg.value >= price, "Insufficient ETH sent");

        // 【致命傷】block.timestamp と blockhash は外部から予測・操作可能
        uint256 insecureRandomness = uint256(
            keccak256(
                abi.encodePacked(
                    block.timestamp,
                    block.prevrandao,
                    msg.sender,
                    block.number
                )
            )
        );

        // 0から99までの数値を生成
        uint256 result = insecureRandomness % 100;

        // 当たり判定(例: 5%の確率)
        if (result < 5) {
            // レアアイテム獲得の処理(実際にはここでNFTをミントしたりする)
        }

        emit Rolled(msg.sender, result);
    }

    // コントラクト内の資金を引き出す関数
    function withdraw() external {
        require(msg.sender == owner, "Only owner");
        payable(owner).transfer(address(this).balance);
    }
}

このコードの最大の欠陥は、roll() 関数が「同じトランザクション内で完結している(即時評価)」という点にある。攻撃者は、自分のコントラクト(スマートコントラクト・ウォレットや悪意あるコントラクト)からこの roll() を呼び出し、もし返り値が不本意なものであれば revert(処理の巻き戻し)を発生させ、ガス代の微小な損失だけで「不運な試行」を無かったことにできる。これをトランザクションレベルのサンドボックスとして悪用されるのだ。

—

最先端の防衛アーキテクチャ:Chainlink VRFを用いた非同期・安全な乱数生成

この脆弱性を完全に断ち切るための唯一にして王道のアプローチは、「オンチェーンでの即時計算を諦め、オフチェーンの暗号学的検証済みランダムネス(VRF: Verifiable Random Function)を非同期でインポートする」ことだ。

業界標準となっているChainlink VRF(Verifiable Random Function)を例に取ろう。この仕組みでは、スマートコントラクトが乱数を要求すると、オラクルネットワークが暗号学的に証明可能な乱数と、それが正しく生成されたことを示す「暗号学的証明(Proof)」を生成し、それをコールバック関数経由でコントラクトに書き戻す。

これにより、以下の2つの防御レイヤが確立される。
1. コミット・リビール(あるいはオラクル分離)モデルの強制: 乱数を要求するトランザクションと、乱数を受け取って処理を確定させるトランザクションが物理的・時間的に分離されるため、先ほどの「リバートによる不正な試行のやり直し」が不可能になる。
2. 証明可能性: オラクルが不正な値を差し挟んでいないことが、数学的(ゼロ知識証明等の技術)に保証される。

以下に、Chainlink VRF v2.5を安全に実装するためのモダンなアーキテクチャのサンプルコードを示す。

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

import {VRFConsumerBaseV2Plus} from "@chainlink/contracts/src/v0.8/vrf/dev/VRFConsumerBaseV2Plus.sol";
import {VRFV2PlusClient} from "@chainlink/contracts/src/v0.8/vrf/dev/libraries/VRFV2PlusClient.sol";

/**
 * @dev 【安全な実装例】Chainlink VRF v2.5を利用した堅牢なガチャコントラクト
 * リクエストとコールバックを非同期に分離し、MEVボットの予測を完全に排除します。
 */
contract SecureGacha is VRFConsumerBaseV2Plus {
    // Chainlink VRFの設定パラメータ
    bytes32 private s_keyHash;
    uint256 private s_subscriptionId;
    uint32 private s_callbackGasLimit = 100000;
    uint16 private s_requestConfirmations = 3;
    uint32 private s_numWords = 1;

    // リクエストIDとプレイヤーアドレスのマッピング
    mapping(uint256 => address) public s_requestIdToPlayer;

    event RequestSent(uint256 indexed requestId, address indexed player);
    event RequestFulfilled(uint256 indexed requestId, uint256 randomNumber);

    constructor(
        address vrfCoordinator,
        bytes32 keyHash,
        uint256 subscriptionId
    ) VRFConsumerBaseV2Plus(vrfCoordinator) {
        s_keyHash = keyHash;
        s_subscriptionId = subscriptionId;
    }

    // 1段階目: プレイヤーが乱数をリクエストする
    function requestRandomGame() external payable {
        // 実際のプロダクションではここでETHの支払いやバリデーションを入れる
        
        // VRFコーディネーターに乱数をリクエストし、リクエストIDを取得
        uint256 requestId = s_vrfCoordinator.requestRandomWords(
            VRFV2PlusClient.RandomWordsRequest({
                keyHash: s_keyHash,
                subId: s_subscriptionId,
                requestConfirmations: s_requestConfirmations,
                callbackGasLimit: s_callbackGasLimit,
                numWords: s_numWords,
                extraArgs: VRFV2PlusClient._argsToBytes(
                    VRFV2PlusClient.ExtraArgsV1({nativePayment: false}) // LINKトークンまたはネイティブトークン支払いの設定
                )
            })
        );

        // リクエストIDとプレイヤーを紐付ける
        s_requestIdToPlayer[requestId] = msg.sender;

        emit RequestSent(requestId, msg.sender);
    }

    // 2段階目: Chainlinkのオラクルから安全な乱数が非同期で返却される(コールバック関数)
    function fulfillRandomWords(
        uint256 requestId,
        uint256[] memory randomWords
    ) internal override {
        address player = s_requestIdToPlayer[requestId];
        require(player != address(0), "Request not found");

        uint256 secureRandomNumber = randomWords[0];
        uint256 result = secureRandomNumber % 100;

        // ここでゲームの結果処理(NFTミントや報酬分配)を安全に実行
        if (result < 5) {
            // レア獲得処理
        }

        emit RequestFulfilled(requestId, result);
    }
}

この実装における最大のポイントは、「ユーザーが結果を知る前に、すでにトランザクションが確定している(リクエストがブロックチェーンに刻まれている)」という点だ。攻撃者はリクエスト送信後に結果をコントロールする術を持たず、オラクルからのコールバックを待つほかない。運命は完全に数学と分散型ネットワークの手に委ねられることになる。

—

セキュリティアーキテクトとしての現場の知見

プロトコルの監査やスマートコントラクトの設計において、私は常に開発者に対してこう忠告している。

「チェーン上のあらゆるデータは『公開情報』であり、コンセンサスのルールに組み込まれた変数を乱数の代替品として使った瞬間、そのシステムは経済的インセンティブを持った攻撃者のターゲットになる」と。

特にL2(Layer 2)環境や、高速なブロック生成を売りにするチェーンにおいては、バリデータやシーケンサー(Sequencer)がトランザクションの順序を意図的に操作する「Censorship(検閲)」や「Front-running(フロントランニング)」の余地が、L1以上に残されている場合がある。安易に「ブロックハッシュで十分だろう」という妥協を許してはならない。

スマートコントラクトセキュリティは、一度デプロイしてハックされれば、コードの修正が効かない(あるいはプロキシを用いた複雑なアップグレードが必要になるが、それ自体が新たな攻撃面を生む)極限の世界だ。

乱数生成という一見地味に見えるコンポーネントの不備が、数百万ドルのドレイン(資金流出)を引き起こす現実を、私たちは幾度となく目撃してきた。本稿で示した非同期オラクルパターンの採用と、トランザクションの分離設計を徹底し、攻撃者が入り込む隙間を徹底的に排除した堅牢なWeb3インフラストラクチャを構築してほしい。

コメント

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