【入門編】 署名リプレイ攻撃(Signature Replay)の防止 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

こんにちは!IoTデバイスのリバースエンジニアリングやWeb3セキュリティの現場を駆け巡っているリサーチャーです。普段は工場の心臓部である制御システム(SCADA)や、ブロックチェーン上のスマートコントラクトを相手に、日々「どうやったら悪意ある攻撃者にハックされるか」を逆算して守る仕事をしています。

さて、今回はWeb3開発の世界に飛び込んだばかりの新人エンジニアや、ブロックチェーンセキュリティに興味を持ち始めたあなたに向けて、スマートコントラクトの代表的な落とし穴である「署名リプレイ攻撃(Signature Replay)」と、その鉄壁の防御策についてお話しします。

難しそうに聞こえるかもしれませんが、身近な「合鍵」と「宅配便の受領印」の防犯に置き換えれば、すぐに納得できますよ。一歩ずつ、優しく紐解いていきましょう!

—

1. 署名リプレイ攻撃ってなに?(身近な例えで理解する)

突然ですが、あなたがオンラインで大切な資産を動かす、あるいはスマートコントラクトに「この操作を許可します」という指示を出す場面を想像してください。

ブロックチェーンの世界では、パスワードを入力する代わりに「デジタル署名」というものを使います。あなたの秘密鍵でメッセージにサインをすることで、「あ、これは間違いなく本人からの指示だな」とスマートコントラクトが確認する仕組みです。

ここで、こんな悪巧みを考える泥棒(攻撃者)を想像してみましょう。

  • 泥棒の企み: あなたが正当に買い物や送金をするために書いた「署名入り伝票」を、悪い奴がコソッと盗み見(盗聴)する。
  • リプレイ(再利用)攻撃の実行: 泥棒は、その盗んだ「本物の署名入り伝票」を、まったく同じ内容のまま、別の場所(あるいは何度も繰り返し)で勝手に使って、あなたの財布からお金を抜き取る。

これを現実の「家の鍵」に例えてみましょう。
あなたが友達に「合鍵」を貸して、一度だけ家の中に入ってもらいました。もし、その合鍵が「何度でも使い回せる魔法の合鍵」だったらどうでしょう? 友達が帰った後、泥棒がその鍵の形をコピーして、勝手に何回もあなたの家に侵入できてしまいますよね。これが、デジタル世界における「署名リプレイ攻撃」の正体です。

—

2. なぜこの攻撃が起きるのか?(脆弱性のメカニズム)

スマートコントラクトが「誰が、何を言っているか」を確認する際、もし単に「このメッセージにサインがあるからOK!」とだけ判定していると危険です。

攻撃者は、次のようなシチュエーションで牙をむきます。
1. クロスチェーン・リプレイ: イーサリアム(メインネット)でした署名のデータが、まったく同じ構造を持つ別のテストネットや、フォークした別チェーンでそのまま通ってしまう。
2. タイムラグ・リプレイ: 過去にあなたが有効な取引のために発行した署名が、キャンセル処理されていないのをいいことに、後から何度も実行されてしまう。

これを防ぐためには、署名の中に「今、どのチェーンで使っているか(チェーンID)」や「この通信は1回限りですよ(ナンス/nonce)」という、いわば「使い捨ての消印」をしっかりと押しておく必要があるのです。

—

3. EIP-712と防御ヘッダー:安全な仕組みの作り方

そこで登場するのが、Ethereumの標準規格である EIP-712 です。
これを使うと、ユーザーに対して「今、あなたはどこで、何のために、どんなデータを承認しようとしているのか」をウォレット(MetaMaskなど)の画面に人間が読める形でハッキリと表示させつつ、内部的には改ざんやリプレイができない強力なデータ構造を作ることができます。

防御の要となる主な要素は以下の3つです。

  • nonce(ナンス): ユーザーごとにカウントアップされる数値。一度使われた nonce は二度と使えないようにコントラクト側でブロックします。
  • chainId(チェーンID): 「この署名はイーサリアムのメインネット専用です!」という網掛け。他のネットワークに持って行っても無効になります。
  • verifyingContract(コントラクトアドレス): どのスマートコントラクト専用の署名かを明確に縛ります。

それでは、実際のSolidityコードを見て、どのように実装されているのか確認してみましょう!

—

4. 実装例:リプレイ攻撃を防ぐスマートコントラクト

以下のコードは、EIP-712の構造を取り入れ、nonce と chainId を使ってリプレイ攻撃を完全にシャットアウトするスマートコントラクトの実装例です。

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

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

// EIP712を継承して、安全な署名検証のベースを作ります
contract SecureActionExecutor is EIP712 {
    using ECDSA for bytes32;

    // ユーザーごとのnonce(使い捨てカウンター)を管理するマッピング
    // 例: 非ースアドレス => 現在のnonce
    mapping(address => uint256) public nonces;

    // EIP-712で定義するデータの型構造(タイプレジストリ)
    bytes32 private constant EXECUTE_TYPEHASH = keccak256(
        "ExecuteData(address user,uint256 amount,uint256 nonce)"
    );

    // コンストラクタで、コントラクト名とバージョンを指定してEIP712を初期化します
    constructor() EIP712("SecureActionExecutor", "1") {}

    // ユーザーが署名した内容を実行する関数
    function executeWithSignature(
        address user,
        uint256 amount,
        uint256 nonce,
        bytes calldata signature
    ) external {
        // 1. nonceのチェック:コントラクト側が持つ最新のnonceと一致しているか確認
        require(nonce == nonces[user], "Invalid nonce: 既に使われたか、順番が間違っています");

        // 2. nonceをインクリメント(使い捨てにするため、次に進める)
        nonces[user]++;

        // 3. EIP-712に基づいたハッシュ(構造化データ)を組み立てる
        // ここには自動的にチェーンIDやこのコントラクトのアドレスも組み込まれます
        bytes32 structHash = keccak256(
            abi.encode(
                EXECUTE_TYPEHASH,
                user,
                amount,
                nonce
            )
        );

        bytes32 hash = _hashTypedDataV4(structHash);

        // 4. 署名者を復元し、それが「user」本人であるかを厳密に検証する
        address signer = hash.recover(signature);
        require(signer == user, "Invalid signature: 署名者が一致しません");

        // --- ここから下に、安全になったあとのビジネスロジックを記述します ---
        // 例: トークンの転送や重要な設定の変更など
    }
}

コードのポイント解説

  • require(nonce == nonces[user], ...) の部分で、古い署名や一度使われた署名を完全に弾いています。泥棒が同じデータをもう一度持ってきても、すでに nonces[user] の値が進んでいるため、エラー(リバート)になります。
  • _hashTypedDataV4 を使っているため、仮に別のチェーン(例えばテストネット)で発行された署名を本番チェーンに持ってきても、チェーンIDが一致せずに弾かれます。

—

5. フロントエンド(JavaScript / TypeScript)側の実装イメージ

スマートコントラクト側だけでなく、ユーザーが操作するフロントエンド側でも正しくEIP-712のデータを作って署名してもらう必要があります。MetaMaskなどのウォレット連携では以下のようなコードを書きます。

// フロントエンド(ethers.js v6などのイメージ)でのEIP-712署名リクエスト
async function signMessage(signer, contractAddress, userAddress, amount, currentNonce, chainId) {
    // ドメイン分離情報(どのチェーンの、どのコントラクトか)
    const domain = {
        name: "SecureActionExecutor",
        version: "1",
        chainId: chainId, // 現在接続しているチェーンIDを必ず含める!
        verifyingContract: contractAddress
    };

    // データの型定義
    const types = {
        ExecuteData: [
            { name: "user", type: "address" },
            { name: "amount", type: "uint256" },
            { name: "nonce", type: "uint256" }
        ]
    };

    // 実際に署名してもらう値
    const value = {
        user: userAddress,
        amount: amount,
        nonce: currentNonce // コントラクトから取得した現在のnonceを指定
    };

    // ウォレットを通じてユーザーに署名を依頼(メタマスク等のポップアップが出る)
    const signature = await signer.signTypedData(domain, types, value);
    
    console.log("生成された安全な署名:", signature);
    return signature;
}

このように、フロントエンドとスマートコントラクトの両方で「チェーンID」と「nonce」をガッチリと管理することで、署名リプレイ攻撃のスキを一切与えない強固なシステムが完成します。

—

最後に:セキュリティは「疑うこと」から始まる

IoT・OTの現場でも、スマートコントラクトの開発でも共通して言えるのは、「ネットワーク上を流れるデータは、すべて盗聴され、改ざんされ、何度も送り直される可能性がある」という性悪説を前提に設計するということです。

「まさか同じ署名をもう一度送ってくるバカはいないだろう」という甘い油断が、億単位のハッキング被害を引き起こします。
ぜひ今回の記事を参考に、あなたのプロジェクトでも nonce と EIP-712 を正しく実装し、泥棒が入り込む隙のない堅牢なシステムを作り上げてくださいね。一歩ずつ、確実にセキュアなエンジニアを目指していきましょう!

コメント

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