【テクニカル・上級編】 EIP-712における型付きデータ署名の検証不備とリプレイ攻撃 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

EIP-712の罠:クロスチェーンリプレイ攻撃と型付きデータ署名の深層解析

スマートコントラクトのセキュリティ監査を行っていると、開発者が「オフチェーン署名=安全」という神話を盲信している現場に幾度となく直面する。特に、複雑なDeFiプロトコルやブリッジコントラクトにおいて、EIP-712の仕様を正しく理解せず実装した結果、致命的なクロスチェーンリプレイ攻撃(Cross-Chain Replay Attack)の踏み台となっているケースが後を絶たない。

生粋のセキュリティリサーチャーとして断言するが、EIP-712の本質は単なる「人間が読めるUIの提供」ではない。あれは、バイトコードの海を漂う生のエントロピー(secp256k1のr, s, v)に、厳密な「文脈(Context)」という名の鎖をつなぐための暗号学的防壁なのだ。

本稿では、EIP-712の実装不備が引き起こす脆弱性の根源を、低レイヤのバイトコード構造とEVMのメモリ挙動から紐解き、実務で即座に使える堅牢な防御アーキテクチャを提示する。

—

1. 脆弱性の根本原因:なぜEIP-712署名は破られるのか

従来のeth_signやpersonal_signでは、任意のバイト列に署名するため、ユーザーは何に署名しているのかを目視できず、盲目的な署名(Blind Signing)が常態化していた。これを解決するために導入されたのがEIP-712である。

しかし、EIP-712が定めた構造化データのハッシュ化プロセス(eip712HashStruct)において、開発者が以下の要素を適切に分離・検証しない場合、攻撃者は異なるチェーンや異なるコントラクト間で署名を再利用(リプレイ)することが可能になる。

1. ドメインセパレータ(Domain Separator)の欠陥
2. chainIdの動的バインドの欠落
3. verifyingContractのハードコードまたは未検証

攻撃者は、あるチェーン(例えばPolygonやArbitrum)でユーザーが正当に行った署名をキャプチャし、全く同じコントラクトアドレスがデプロイされている(あるいはプロキシの不備をつける)別のチェーンへと流し込む。EVM上では、ecrecoverが返すアドレスが一致しさえすれば、それがどのネットワークで生成された署名であるかをデフォルトでは関知しない。ここに最大の盲点がある。

—

2. 脆弱な実装パターンと攻撃シナリオ

まずは、セキュリティ監査で頻繁に発見される「危殆化したEIP-712実装」を見てみよう。以下のコードは、一見すると標準的なEIP-712に準拠しているように見えるが、致命的な欠陥を抱えている。

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

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

// 【危険な実装例】chainIdやverifyingContractの検証が不十分なコントラクト
contract VulnerablePermitBridge {
    using ECDSA for bytes32;

    // 脆弱なEIP-712のタイプハッシュ
    bytes32 public constant TRANSFER_TYPEHASH = keccak256(
        "Transfer(address to,uint256 amount,uint256 nonce)"
    );

    mapping(address => uint256) public nonces;
    mapping(bytes32 => bool) public executed;

    // ドメインセパレータの構築においてchainIdやコントラクトアドレスをハードコードしている、
    // あるいはコンストラクタで動的に設定していない場合
    bytes32 public immutable DOMAIN_SEPARATOR;

    constructor() {
        DOMAIN_SEPARATOR = keccak256(
            abi.encode(
                keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
                keccak256(bytes("VulnerableBridge")),
                keccak256(bytes("1")),
                1, // <-- ハードコードされたchainId (例: Ethereum Mainnet)
                address(this)
            )
        );
    }

    function transferWithSignature(
        address to,
        uint256 amount,
        uint256 nonce,
        bytes calldata signature
    ) external {
        require(nonce == nonces[msg.sender], "Invalid nonce");
        
        // 構造化データのハッシュ化
        bytes32 structHash = keccak256(
            abi.encode(
                TRANSFER_TYPEHASH,
                to,
                amount,
                nonce
            )
        );

        // ダイジェストの生成
        bytes32 digest = keccak256(
            abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, structHash)
        );

        // 署名者の復元
        address signer = digest.recover(signature);
        require(signer != address(0), "Invalid signature");
        require(!executed[digest], "Already executed");

        executed[digest] = true;
        nonces[msg.sender]++;

        // 実際の転送処理(省略)
    }
}

この実装の何が問題なのか?

もしこのコントラクトが、Ethereum Mainnet(Chain ID: 1)だけでなく、OptimismやBaseなどのL2チェーンに同じアドレス(CREATE2や安価な初期化手法の悪用による)でデプロイされた場合を想像してほしい。

コンストラクタでchainIdを 1 にハードコードしている、あるいはデプロイ時のチェーン環境を正しく反映していない場合、L2上で生成された署名やメインネットで生成された署名が、他のチェーンの同名コントラクトでそのまま有効になってしまう。攻撃者はユーザーにL2上で「無害なトランザクション」と偽って署名させ、その署名をメインネット側のコントラクトへ投下することで、不正な資産移動を引き起こす。

—

3. 堅牢なアーキテクチャ:OpenZeppelinを活用した安全なEIP-712実装

この脆弱性を完全に断ち切るためには、OpenZeppelinのEIP712ライブラリを継承し、ドメインセパレータに現在の実行コンテキスト(block.chainid と address(this))を動的にバインドさせる必要がある。

以下に、実務のプロダクション環境で使用すべきセキュアな実装を示す。

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

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

// 【セキュアな実装例】EIP712を正しく継承し、リプレイ攻撃を防止するコントラクト
contract SecureBridge is EIP712, ReentrancyGuard {
    using ECDSA for bytes32;

    // タイプハッシュの定義
    bytes32 public constant TRANSFER_TYPEHASH = keccak256(
        "Transfer(address to,uint256 amount,uint256 nonce,uint256 deadline)"
    );

    mapping(address => uint256) public nonces;
    
    // 再生攻撃防止のための実行済み管理(ダイジェストベース)
    mapping(bytes32 => bool) public executed;

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

    // コンストラクタで名前とバージョンを渡す。
    // OpenZeppelinのEIP712内部で、block.chainidとaddress(this)を用いた動的なドメインセパレータが計算される。
    constructor() EIP712("SecureBridge", "1") {}

    function transferWithSignature(
        address to,
        uint256 amount,
        uint256 nonce,
        uint256 deadline,
        bytes calldata signature
    ) external nonReentrant {
        require(block.timestamp <= deadline, "Signature expired");
        require(nonce == nonces[msg.sender], "Invalid nonce");

        // 1. 構造化データのハッシュ化
        bytes32 structHash = keccak256(
            abi.encode(
                TRANSFER_TYPEHASH,
                to,
                amount,
                nonce,
                deadline
            )
        );

        // 2. EIP-712に準拠したダイジェストの生成 (_hashTypedDataV4を使用)
        // これにより、現在のchainIdとコントラクトアドレスが自動的にドメインセパレータに組み込まれる
        bytes32 digest = _hashTypedDataV4(structHash);

        // 3. 署名者の復元と検証
        address signer = digest.recover(signature);
        require(signer != address(0), "Invalid signature");
        require(!executed[digest], "Signature already executed");

        // 4. ステートの更新(Checks-Effects-Interactionsパターン)
        executed[digest] = true;
        nonces[msg.sender]++;

        // 5. アセットの転送ロジック
        // _safeTransfer(signer, to, amount);

        emit TransferExecuted(signer, to, amount, nonce);
    }

    // 外部から現在のドメインセパレータを確認するためのヘルパー(フロントエンド連携用)
    function getDomainSeparator() external view returns (bytes32) {
        return _domainSeparatorV4();
    }
}

—

4. フロントエンド・オフチェーン側の実装における注意点

スマートコントラクト側どれほど強固に組んでいても、オフチェーン(TypeScript / ethers.js v6 / viem 等)側の実装でミスがあれば、ユーザーはウォレット上で予期せぬデータに署名させられる。特に、ウォレットの拡張機能やDAppsのUI層における型定義の不一致は、インシデントの温床となる。

以下に、安全なEIP-712ペイロードの構築と署名依頼を行うTypeScriptのコード例を示す。

import { ethers } from "ethers";

// フロントエンド側での厳密な型定義とドメイン設定
async function signTransferData(
    signer: ethers.Signer,
    verifyingContractAddress: string,
    chainId: number,
    recipient: string,
    amount: bigint,
    nonce: bigint,
    deadline: bigint
) {
    // EIP-712 Domainの定義 (コントラクト側の設定と完全に一致させる必要がある)
    const domain = {
        name: "SecureBridge",
        version: "1",
        chainId: chainId, // 動的に取得した正しいチェーンIDを渡す
        verifyingContract: verifyingContractAddress,
    };

    // 構造化データのタイプ定義
    const types = {
        Transfer: [
            { name: "to", type: "address" },
            { name: "amount", type: "uint256" },
            { name: "nonce", type: "uint256" },
            { name: "deadline", type: "uint256" },
        ],
    };

    // 署名対象の値
    const value = {
        to: recipient,
        amount: amount,
        nonce: nonce,
        deadline: deadline,
    };

    // ユーザーのウォレット(MetaMask等)にEIP-712形式で署名を要求
    // これにより、ウォレットのUI上に構造化されたデータが人間が読める形式で表示される
    const signature = await signer.signTypedData(domain, types, value);
    
    return signature;
}

テックリードやセキュリティアーキテクトは、CI/CDパイプラインやユニットテストにおいて、「意図したchainId以外からの署名が確実にリジェクトされるか」「デプロイ先が異なる環境間で署名がクロスファイアしないか」をファジングテスト(Foundryのvm.sign等を使用)で必ず検証しなければならない。

—

結びにかえて

ブロックチェーンのセキュリティにおいて、「動かないこと」と「安全であること」は同義ではない。EIP-712は、適切に実装されていれば強力な盾となるが、ドメインセパレータの設計を一つ誤るだけで、それは攻撃者にとって最高の「マルチチェーン強奪ツール」へと変貌する。

泥臭いコードの隅々まで目を光らせ、コンテキストの剥離を見逃さないこと。それこそが、真のセキュリティアーキテクトに求められる姿勢である。

コメント

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