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

おい、ちょっと手を止めてこっちを向いてくれ。

先日、とあるクライアントのWeb3プロジェクトで、オフチェーン署名周りのコードレビューをしたんだ。そうしたら、まあ見事に「署名リプレイ攻撃(Signature Replay)」の地雷原を全踏みしている実装が出てきてね。危うく数億円規模のドレストランザクションが外部の攻撃者に丸呑みされるところだった。

「ガス代(手数料)を節約させるために、ユーザーにガスレスでメタトランザクション(EIP-2771など)をさせたいんです!」というエンジニアの気持ちは痛いほど分かる。サーバー側で personal_sign を使って署名させ、それをスマートコントラクトに流し込む……。

だがな、nonceの管理をサボり、ドメイン分離(EIP-712)をケチったオフチェーン署名は、鍵束をそのへんの道端にばら撒いて歩いているようなものだ。攻撃者は、一度キャプチャした正当な署名を別のコンテキストやチェーン、果ては同じコントラクト内の別関数に何度でも送り込み、アカウントを綺麗に空っぽにする。

今日は、この最悪なリプレイ攻撃の仕組みと、それを実務レベルで完全に封じ込めるためのEIP-712実装、そしてスマートコントラクト側でのガチガチな防御策を、俺が現場のノウハウを交えて徹底的に叩き込んでやる。心して聞いてくれ。

—

1. なぜ「生のメッセージ署名」は地雷なのか?(攻撃シナリオ)

よくある一番ダメな実装パターンを思い出してほしい。フロントエンドで以下のようなメッセージを ethers.js あたりの signMessage で署名させていないか?

// 【絶対に真似してはいけない危険なアンチパターン】
const message = "Transfer 100 TOKENS to 0xAttacker...";
const signature = await signer.signMessage(message);

これの何がヤバいか。攻撃者は、ユーザーが一度実行した(あるいは別の正当な操作で生成した)この signature を盗聴し、以下の手口で悪用する。

1. クロスチェーン・リプレイ: イーサリアムメインネット用に署名されたトランザクションが、同じアドレス体系を持つL2(PolygonやArbitrumなど)やテストネットでそのまま通ってしまう。
2. 同一チェーン内リプレイ(無限引き出し): コントラクト側に nonce のインクリメント処理がない場合、同じ署名を何百回もスマートコントラクトに叩き込まれ、残高が底を突くまでトークンが吸い取られる。
3. 別コントラクトへのクロスコントラクト・リプレイ: 似たようなインターフェースを持つ別のスマートコントラクトに同じ署名を送り、そちらでも処理を強制実行させる。

攻撃者はスマートコントラクトのバイトコードを逆アセンブルし、ecrecover の実装ミスや nonce のチェック漏れをハイエナのように狙っている。現場のエンジニアが「動けばいいや」と書いたその数行のコードが、会社を傾かせるインシデントに直結するんだ。

—

2. EIP-712とNonce管理による完全防衛

この悪夢を防ぐための黄金律が、EIP-712(Typed Structured Data Hashing and Signing) と ユーザーごとの厳格なNonce管理 だ。

EIP-712を導入すると、ウォレットのUI(MetaMaskなど)に、単なるHEX文字や不気味な文字列ではなく、「どこの、どのコントラクトの、何の操作のために、いくら移動させるのか」が人間にとって分かりやすい構造化データとして表示されるようになる。これだけでフィッシング詐欺や意図しない署名のリスクが激減する。

そして、スマートコントラクト側では以下の2点を必ず強制する。

  • 署名データに chainId を含め、チェーン間のリプレイを物理的に不可能にする。
  • 署名データに verifyingContract(コントラクト自身のアドレス)を含め、別コントラクトへの流用を防ぐ。
  • アドレスごとの nonce をマッピングで管理し、一度使われた署名は二度と使えないようにハッシュ化またはインクリメントする。

—

3. 【実装サンプル】堅牢なスマートコントラクトと署名検証ロジック

百聞は一見にしかずだ。SolidityにおけるEIP-712準拠のセキュアな署名検証と、リプレイ攻撃を防ぐための実装コードを共有する。そのままプロダクションに組み込めるクオリティにしてある。

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

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

/**
 * @title SecurePermitProcessor
 * @brief EIP-712とNonce管理により署名リプレイ攻撃を完全に排除したセキュアコントラクト
 */
contract SecurePermitProcessor is EIP712, Ownable {
    using ECDSA for bytes32;

    // ユーザーごとのリプレイ防止用Nonce
    mapping(address => uint256) public nonces;

    // EIP-712で定義する構造体のタイプハッシュ
    bytes32 private constant TRANSFER_TYPEHASH = keccak256(
        "TransferRequest(address recipient,uint256 amount,uint256 nonce,uint256 deadline)"
    );

    // イベント定義
    event TransferExecuted(address indexed sender, address indexed recipient, uint256 amount, uint256 nonce);

    constructor() EIP712("SecurePermitProcessor", "1") Ownable(msg.sender) {}

    /**
     * @notice オフチェーン署名を用いたセキュアな転送処理
     * @param recipient 受信者アドレス
     * @param amount 転送量
     * @param deadline 有効期限(Unixタイムスタンプ)
     * @param signature ユーザーがオフチェーンで生成した署名(r, s, v)
     */
    function executeWithAuthorization(
        address recipient,
        uint256 amount,
        uint256 deadline,
        bytes calldata signature
    ) external {
        // 1. 有効期限のチェック(期限切れの署名は即座にreject)
        require(block.timestamp <= deadline, "Signature expired");

        // 2. 現在のユーザーのnonceを取得し、リプレイ対策のハッシュを構築
        uint256 currentNonce = nonces[msg.sender];
        
        // EIP-712の仕様に基づき、ドメインセパレータと構造体ハッシュを結合
        bytes32 structHash = keccak256(
            abi.encode(
                TRANSFER_TYPEHASH,
                recipient,
                amount,
                currentNonce,
                deadline
            )
        );
        
        bytes32 hash = _hashTypedDataV4(structHash);

        // 3. 署名者アドレスの復元と検証
        address signer = hash.recover(signature);
        
        // メッセージの作成者が呼び出し元(または許可された権限者)と一致するか
        // ※メタトランザクションの場合は、signerを動的に抽出して処理する
        require(signer != address(0) && signer == msg.sender, "Invalid signature");

        // 4. Nonceをインクリメント(※必ず処理の実行前にインクリメントしてReentrancyおよびリプレイを阻止)
        nonces[signer] = currentNonce + 1;

        // 5. 実際のビジネスロジック(トークン転送など)を実行
        // (省略:ここに実際の転送処理を記述)

        emit TransferExecuted(signer, recipient, amount, currentNonce);
    }

    /**
     * @notice 現在のユーザーのNonceを確認するためのヘルパー関数
     */
    function getNonce(address user) external view returns (uint256) {
        return nonces[user];
    }
}

このコードのポイントを解説しておこう。
まず、OpenZeppelinの EIP712 コントラクトを継承している点だ。これにより、コンストラクタで指定した名称(SecurePermitProcessor)とバージョン(1)が自動的に現在の block.chainid および address(this) とバインドされる。つまり、別のチェーンや別のコントラクトにこの署名をコピーしても、_hashTypedDataV4 が生成するハッシュ値が変わるため、ECDSA.recover で得られるアドレスが一致せず、確実に弾かれる仕組みになっている。

さらに、処理の途中で nonces[signer] = currentNonce + 1; とインクリメントを行っている。仮に攻撃者が同じ署名をもう一度送り込んできても、コントラクト側が保持する nonces[signer] の値がすでに進んでいるため、structHash の値が変わってしまい、署名検証が失敗する。これが完璧なリプレイ防止のメカニズムだ。

—

4. フロントエンド(JavaScript / ethers.js)側の実装Tips

スマートコントラクト側をどれだけガチガチに固めても、フロントエンド側がEIP-712の仕様に沿った正しいJSON構造を組み立てていなければ、ユーザーのMetaMask上で正しく署名が生成できない。

実務でそのまま使える、ethers.js v6 を使ったEIP-712署名の実装サンプルを置いておく。

import { ethers } from "ethers";

async function signTransferRequest(signer, recipientAddress, transferAmount, deadlineTimestamp, contractAddress, chainId) {
    // 1. ドメインの定義(チェーンIDとコントラクトアドレスを必ず含める)
    const domain = {
        name: "SecurePermitProcessor",
        version: "1",
        chainId: chainId,
        verifyingContract: contractAddress
    };

    // 2. 契約側で定義した構造体と完全に一致させるTypes
    const types = {
        TransferRequest: [
            { name: "recipient", type: "address" },
            { name: "amount", type: "uint256" },
            { name: "nonce", type: "uint256" },
            { name: "deadline", type: "uint256" }
        ]
    };

    // 3. バックエンドまたはコントラクトから現在のnonceを取得しておく
    // (ここでは説明のため仮の関数呼び出しとする)
    const userAddress = await signer.getAddress();
    const currentNonce = await fetchUserNonceFromContract(userAddress, contractAddress);

    // 4. 署名対象の値(Value)の構築
    const value = {
        recipient: recipientAddress,
        amount: ethers.parseUnits(transferAmount.toString(), 18),
        nonce: currentNonce,
        deadline: deadlineTimestamp
    };

    try {
        // 5. MetaMask等のウォレットを呼び出してEIP-712署名を実行
        // これにより、ユーザーのウォレット画面にリッチな確認ダイアログが表示される
        const signature = await signer.signTypedData(domain, types, value);
        
        console.log("署名生成成功:", signature);
        return { signature, value };
    } catch (error) {
        console.error("ユーザーによって署名が拒否されました、またはエラーが発生しました:", error);
        throw error;
    }
}

フロントエンドの実装において最も多いミスが、types のデータ型(address, uint256 等)や変数名をSolidity側と微妙にタイポさせることだ。キー名が一文字でも違えば生成されるハッシュ値がズレてしまい、コントラクト側で永遠に Invalid signature のエラーに悩まされることになる。ユニットテストの段階で、オンチェーンの検証とフロントエンドのハッシュ値が完全に一致しているかを必ずモックテストで検証してくれ。

—

5. シニアセキュリティチーフからの現場の教訓

最後に、インシデントレスポンスの現場から一言。

セキュリティというのは、一つの強固な壁を作れば終わりというものではない。今回紹介したEIP-712とNonce管理は、いわば「玄関のオートロックと二重防犯カメラ」のようなものだ。しかし、どれだけ鍵を頑丈にしても、開発者がテスト環境の秘密鍵(Private Key)を誤ってGitHubのパブリックリポジトリにプッシュしてしまったり、依存しているnpmパッケージ(サプライチェーン)に悪意あるコードが混入していれば、一瞬でゲームオーバーだ。

コードを書くときは常にこう自問自答してほしい。
「この署名は、万が一ネットワーク上で傍受され、全く関係ない悪意ある第三者に別の文脈で再利用されたとき、システムは耐えられるか?」

この視点を持つだけで、君たちが書くコードの安全性は劇的に跳ね上がる。次のデプロイの前に、もう一度チームのコントラクトの nonce と domain separator の実装を確認してみてくれ。

それじゃあ、今日もセキュアで美しいコードを書こうぜ。質問があればいつでも俺のところへ来い。

コメント

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