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

署名リプレイ攻撃の解剖学:Web3とOT/IoT境界領域における暗号学的アイデンティティの崩壊

スマートコントラクトのセキュリティ監査において、いまだに最も見落とされ、かつ致命的な破壊力を持つのが「署名リプレイ攻撃(Signature Replay Attack)」だ。
オフチェーンで生成された暗号学的署名が、意図されたコンテキストの境界を越えて再利用され、資産の不正流出や不正な状態遷移を引き起こす。この脆弱性は、単なる「コードのバス」ではなく、分散型システムにおける「アイデンティティと文脈の結合不足」に起因する根本的なアーキテクチャの欠陥である。

本稿では、EVM(Ethereum Virtual Machine)ベースのスマートコントラクトにおける署名リプレイのメカニズムを低レイヤのデータ構造から解き明かし、EIP-712による構造化データの厳密なドメイン分離と、暗号学的nonce管理を組み合わせた鉄壁の防御アーキテクチャを実践的なコードとともに提示する。

—

1. 根本原因の解析:なぜ署名は再利用されるのか?

攻撃者がオフチェーンで署名されたメッセージ(バイナリペイロード)を傍受し、それを全く異なる文脈(別のコントラクト、別のチェーン、あるいは同じコントラクトの異なる時点)で再送する――これがリプレイ攻撃の基本形だ。

EVMのコンテキストにおいて、ecrecoverプリコンパイルコントラクトは、与えられたhashと署名成分(v, r, s)から、署名を行ったEOA(Externally Owned Account)のアドレスを復元する。ここで発生する脆弱性の本質は以下の3点に集約される。

1. ドメイン分離の欠如(Cross-Contract / Cross-Chain Replay):
メッセージハッシュに、それがどのコントラクトのどの関数で、どのチェーンID(block.chainid)のために意図されたものかというメタデータが含まれていない場合、同一の秘密鍵で署名されたデータは、すべてのEVM互換チェーンや任意のコントラクトで有効なパスポートとして機能してしまう。
2. 文脈の欠落(Plain Hash Signing):
keccak256(abi.encodePacked(...))を用いた生のハッシュ署名は、データの型情報やフィールド名を隠蔽するため、攻撃者が巧妙に細工した別のデータ構造に同じハッシュを一致させることが可能になる。
3. 一回性の保証(Nonce)の欠如:
タイムスタンプやインクリメンタルなnonceによる状態変化が検証ロジックに組み込まれていない場合、一度ブロックチェーン上に記録されたトランザクション(の入力データ)を、未来永劫にわたって何回でも再実行可能になる。

—

2. EIP-712による構造化データの防衛線

生のbytes32ハッシュへの署名から脱却し、構造化データへの型付き署名を実現するのが EIP-712 だ。EIP-712は、ウォレットのUI上でユーザーが「何を承認しているのか」を人間が読める形で提示すると同時に、暗号学的なドメイン分離を強制する。

EIP-712のハッシュ構造は、以下の2つのハッシュの組み合わせによって構築される。

  • domainSeparator: コントラクトの固有性(名前、バージョン、チェーンID、検証先コントラクトアドレス)を定義する。
  • structHash: 実際のビジネスロジックのデータ構造とその値をエンコードしたハッシュ。

この2つを結合した最終的なハッシュ値に対して署名を行うため、別のチェーンや別のコントラクトに持ち出された瞬間に domainSeparator の不一致により検証が失敗する仕組みになっている。

—

3. 実装パターン:EIP-712とNonce管理を網羅したセーフティコントラクト

以下の Solidity コードは、クロスチェーンリプレイおよび同一コンテキストでのリプレイを完全に排除した、堅牢なオフチェーン署名検証パターンの実装例である。

// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;

/**
 * @title SecureSignVerifier
 * @notice EIP-712と一意なnonce管理により署名リプレイ攻撃を完全に防御するコントラクト
 */
contract SecureSignVerifier {
    // EIP-712のドメインセパレーターを計算するための定数ハッシュ
    // keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
    bytes32 private constant EIP712_DOMAIN_TYPEHASH = 
        0x8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f;

    // 転送データの構造体を定義する型ハッシュ
    // keccak256("TransferAuthorization(address to,uint256 amount,uint256 nonce,uint256 deadline)")
    bytes32 private constant TRANSFER_AUTHORIZATION_TYPEHASH = 
        0x6e76550f24e93c21a473f32c1c68e1e7f62d857a2c85b5177263b8c6f49e496a;

    bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
    uint256 private immutable _CACHED_CHAIN_ID;

    // ユーザーごとのnonce管理 (Replay Attack防止の要)
    mapping(address => uint256) public nonces;

    // 処理済みトランザクションの記録(万が一の二重実行防止用)
    mapping(bytes32 => bool) public executedSignatures;

    event TransferExecuted(address indexed from, address indexed to, uint256 amount, uint256 nonce);

    constructor() {
        _CACHED_CHAIN_ID = block.chainid;
        _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator();
    }

    /**
     * @notice 現在のチェーンIDに対応するドメインセパレーターを取得(ハードフォーク対策含む)
     */
    function domainSeparator() public view returns (bytes32) {
        if (block.chainid == _CACHED_CHAIN_ID) {
            return _CACHED_DOMAIN_SEPARATOR;
        } else {
            return _buildDomainSeparator();
        }
    }

    function _buildDomainSeparator() private view returns (bytes32) {
        return keccak256(
            abi.encode(
                EIP712_DOMAIN_TYPEHASH,
                keccak256(bytes("SecureService")), // アプリケーション名
                keccak256(bytes("1")),       // バージョン
                block.chainid,                       // 現在のチェーンID(クロスチェーンリプレイ防止)
                address(this)                        // コントラクト自身のアドレス(クロスコンテキスト防止)
            )
        );
    }

    /**
     * @notice オフチェーン署名を用いた安全なトークン転送実行関数
     * @param to 受信者アドレス
     * @param amount 送金金額
     * @param deadline 有効期限(Unixタイムスタンプ)
     * @param v 署名のv要素
     * @param r 署名のr要素
     * @param s 署名のs要素
     */
    function executeWithAuthorization(
        address to,
        uint256 amount,
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        // 1. 時間的有効性の検証(期限切れの署名を排除)
        require(block.timestamp <= deadline, "SecureSignVerifier: signature expired");

        // 2. ユーザー固有の現在のnonceを取得
        address signer = msg.sender; // または関数引数として署名者を復元する場合は別途処理
        uint256 currentNonce = nonces[signer];

        // 3. EIP-712に準拠した構造体ハッシュの生成
        bytes32 structHash = keccak256(
            abi.encode(
                TRANSFER_AUTHORIZATION_TYPEHASH,
                to,
                amount,
                currentNonce,
                deadline
            )
        );

        // 4. 最終的なEIP-712メッセージハッシュの生成
        bytes32 digest = keccak256(
            abi.encodePacked("\x19\x01", domainSeparator(), structHash)
        );

        // 5. 署名からアドレスを復元
        address recoveredSigner = ecrecover(digest, v, r, s);
        require(recoveredSigner != address(0), "SecureSignVerifier: invalid signature");
        
        // 6. Nonceと状態のインクリメント(リプレイ攻撃の無効化)
        // ユーザーのnonceをインクリメントすることで、古いnonceを持つ署名は二度と使えなくなる
        nonces[recoveredSigner] = currentNonce + 1;

        // 7. ビジネスロジックの実行
        // (ここでは簡易的にイベント発行のみだが、実際にはトークン移動等を行う)
        emit TransferExecuted(recoveredSigner, to, amount, currentNonce);
    }
}

—

4. フロントエンドおよびオフチェーン側の実装要件

スマートコントラクト側でどれほど完璧な防御を実装しても、オフチェーン側のウォレット連携(TypeScript / ethers.js や viem)で型定義(types)やドメイン定義(domain)を誤れば、署名は無効化されるか、脆弱性を孕んだままになる。

以下は、安全なEIP-712メッセージを構築・署名するためのTypeScriptの実装スニペットだ。

import { ethers } from "ethers";

// フロントエンド側でのEIP-712署名生成ロジック
async function signTransferAuthorization(
  signer: ethers.Signer,
  verifyingContractAddress: string,
  chainId: number,
  to: string,
  amount: bigint,
  nonce: bigint,
  deadline: bigint
) {
  // 1. EIP-712 ドメインの定義
  const domain = {
    name: "SecureService",
    version: "1",
    chainId: chainId,
    verifyingContract: verifyingContractAddress,
  };

  // 2. データ型(Types)の定義。コントラクト側と完全に一致させること
  const types = {
    TransferAuthorization: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
      { name: "nonce", type: "uint256" },
      { name: "deadline", type: "uint256" },
    ],
  };

  // 3. 署名対象の値(Value)
  const value = {
    to: to,
    amount: amount,
    nonce: nonce,
    deadline: deadline,
  };

  // 4. ウォレットを用いて構造化データに署名を要求(MetaMask等のウォレットにポップアップが表示される)
  const signature = await signer.signTypedData(domain, types, value);

  // 5. 署名を r, s, v に分割
  const sig = ethers.Signature.from(signature);
  return {
    v: sig.v,
    r: sig.r,
    s: sig.s,
  };
}

この実装において重要なのは、chainId を動的に取得し、ハードコードしないことだ。チェーンフォークやマルチチェーン展開時において、古いチェーンIDを内包した署名データが作成されるのを防ぐ防波堤となる。

—

5. 監査・セキュリティレビュー時のチェックリスト

チーフホワイトハッカーやセキュリティアーキテクトとしてコントラクトを監査する際、以下のポイントを徹底的に突くべきである。

1. ecrecover の直接使用の排除:
コードベース内に ecrecover が生で使われていないか。使われている場合、前後に \x19\x01 プレフィックスとドメインセパレーターの検証が存在するかを確認する。
2. Nonceのスコープとインクリメント漏れの確認:
nonce がグローバルではなく、アカウント単位(mapping(address => uint256))で管理されているか。また、検証成功時に確実に nonce++ が実行されているか(CEIパターン: Checks-Effects-Interactions の遵守)。
3. タイムスタンプ依存関係の罠:
block.timestamp を用いた有効期限(deadline)の検証において、マイナーの時刻操作(最大数秒〜数分程度のズレ)に対する耐性が考慮されているか。
4. マルチチェーン展開時のリスク:
L2ネットワークやサイドチェーン(Arbitrum, Optimism, Polygon等)へ同一アドレスでコントラクトをデプロイした際、chainId がドメインセパレーターに組み込まれていないと、L1での署名がL2でそのまま悪用される。block.chainid の動的バインドは必須である。

署名リプレイ攻撃は、暗号学的な正当性とアプリケーション側の文脈の不整合を突く、極めてエレガントかつ破壊的な攻撃手法だ。プロトコルの設計段階から「この署名はいつ、どこで、誰によって、何回使われるべきか」をコードの細部にまで刻み込むこと――それこそが、真にセキュアなWeb3アーキテクチャの条件である。

コメント

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