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

EIP-712を無視する者は「署名リプレイ」の餌食になる。現場のリアルな防衛術

「署名さえあれば本人確認は完璧だ」と盲信している開発者は、残念ながら我々のような攻撃者から見れば格好のターゲットだ。特にWeb3とIoT/OTが融合する領域では、単なる秘密鍵の署名だけでは到底足りない。

今日は、スマートコントラクトのセキュリティにおいて「初歩的だが致命的」なミスである、署名リプレイ攻撃をEIP-712でいかに封殺するか、現場の泥臭い実体験を交えて解説する。

—

1. なぜ「生の署名」が危険なのか?(PoCのリスク)

オフチェーンで生成した署名をスマートコントラクトに送信する際、データ構造を定義せずに単なるメッセージを署名しているプロジェクトをよく見かける。これが最大の過ちだ。

攻撃者の視点に立てば、この脆弱性は宝の山だ。
1. インターセプト: ネットワーク通信やフロントエンドのログから署名データ(v, r, s)を盗み出す。
2. リプレイ: 盗んだ署名を、全く別のタイミングや別のコントラクト、あるいは別のチェーン(EVM互換チェーン等)へ再送する。
3. 被害: ユーザーが意図しない送金や権限変更が、完全に「正規の署名済みリクエスト」として処理される。

これを防ぐための標準が EIP-712 だ。構造化されたデータにドメイン分離(ChainIDやコントラクトアドレス等)を加えることで、その署名が「どこで、何のために、誰が」有効なのかを強制的に定義する。

—

2. 実装の極意:EIP-712を用いたセキュアな構造

EIP-712を実装する際は、単に型を定義するだけでなく、Nonce(ナンス)管理を忘れてはならない。署名にインクリメントされるnonceを含めることで、一度使用した署名を二度と使えないようにするのだ。

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

まずはクライアント側で、厳密に型定義された署名を生成するコードだ。

// EIP-712のドメイン定義(チェーンIDを含めるのが必須)
const domain = {
    name: 'Secure-OT-Gateway',
    version: '1',
    chainId: 1, // メインネット環境
    verifyingContract: '0xYourContractAddressHere'
};

// 署名対象の型定義
const types = {
    ActionRequest: [
        { name: 'targetDeviceId', type: 'string' },
        { name: 'action', type: 'string' },
        { name: 'nonce', type: 'uint256' } // リプレイ防止の鍵
    ]
};

// 署名するデータ
const message = {
    targetDeviceId: 'OT_CONTROLLER_001',
    action: 'SHUTDOWN',
    nonce: 1 // DBまたはコントラクトから取得した現在のnonce
};

// 署名の生成
const signature = await signer._signTypedData(domain, types, message);
console.log("生成された署名:", signature);

—

3. スマートコントラクト側での検証ロジック

コントラクト側では、受け取ったデータが正しいnonceを持っているか、そして署名者が意図した本人であるかをecrecover等で検証する。

// Solidityによる検証ロジックの断片
function executeAction(bytes32 r, bytes32 s, uint8 v, uint256 nonce, string memory action) public {
    // 1. Nonceの検証(リプレイ防止)
    require(nonce == userNonces[msg.sender], "Invalid nonce: 重複した署名です");
    
    // 2. EIP-712署名の復元と検証
    bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
        keccak256("ActionRequest(string targetDeviceId,string action,uint256 nonce)"),
        keccak256(bytes(targetDeviceId)),
        keccak256(bytes(action)),
        nonce
    )));
    
    address signer = ecrecover(digest, v, r, s);
    require(signer == msg.sender, "署名者が一致しません");

    // 3. Nonceのインクリメント(次回の攻撃を確実にブロック)
    userNonces[msg.sender]++;
    
    // 以下、OTデバイスへのコマンド発行等の処理
}

—

4. 現場のセキュリティチーフからの教訓

私が過去に調査したインシデントの多くは、このnonce管理の漏れ、あるいはchainIdをハードコードしていないことによる「クロスチェーン・リプレイ」が原因だった。

現場で守るべき3つの鉄則:
1. chainIdを必ず含める: テストネットでの署名がメインネットで流用されるのを防ぐ。
2. Nonceはオンチェーンで管理: クライアント任せのNonceは、攻撃者が任意の値を指定できるため論外だ。
3. ログ監視の徹底: nonceエラーが連続しているIPは、攻撃の予兆である可能性が極めて高い。WAFやクラウド側のIAMポリシーで即座に遮断するフローを組んでおこう。

セキュリティとは、「完璧な防御」を目指すことではない。「攻撃者のコストを、彼らが諦めるレベルまで引き上げる」ことだ。EIP-712はそのための最も効率的で強力な武器になる。

次にコードをコミットする時、君が署名の中身を信頼しきっていないことを期待している。それが、この業界で生き残るための唯一の流儀だ。

コメント

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