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

EIP-712の「落とし穴」:クロスチェーン攻撃を許さない署名検証の鉄則

現場でコードをレビューしていると、EIP-712を「なんとなく」実装してしまっているエンジニアが非常に多い。確かに、構造化データを人間が読める形式で署名させることはUX上重要だ。しかし、ドメインセパレータの定義をサボった結果、あるチェーンで署名されたトランザクションが別のチェーンでそのまま再利用される「クロスチェーンリプレイ攻撃」の温床になっているケースが後を絶たない。

今日は、なぜあなたの署名実装が攻撃者に狙われるのか、その泥臭い現実と防御の最前線を共有する。

なぜ「ドメインセパレータ」で妥協してはいけないのか

EIP-712の核心は、署名対象のデータが「どのアプリケーションの」「どのチェーンの」ものかを明確に区別することにある。

もし chainId の検証を省略したり、ハードコードされた固定値を使ったりすれば、攻撃者はこう動く。
1. 正規のチェーンAでユーザーに署名を要求させる。
2. 取得した署名とデータを、攻撃用チェーンB(テストネットや、同じEVM互換の別ネットワーク)のコントラクトに流し込む。
3. chainId のチェックが甘ければ、コントラクトは「有効な署名」と判断し、資産が不正に引き出される。

これは単なる理論上の脆弱性ではない。過去には、この「セパレータの境界線」の曖昧さを突かれ、数億円単位のトークンが不正移動した事例がいくつもある。

実践:セキュアなEIP-712検証(JavaScript/Ethers.js編)

フロントエンドからバックエンドまで、署名検証を強固にするためには、動的なドメイン構築が必須だ。以下に、現在最も安全とされる実装例を示す。

/**
 * 安全なEIP-712ドメインセパレータの生成
 * ハードコードは厳禁。必ず接続中のchainIdを動的に取得する。
 */
async function getDomain(signer) {
    const chainId = await signer.getChainId(); // 現在のチェーンIDを動的に取得
    return {
        name: 'MySecureApp',      // アプリ固有の名前
        version: '1',             // バージョン管理を徹底する
        chainId: chainId,         // ここが防御の要
        verifyingContract: '0x...', // 署名を検証するコントラクトアドレス
    };
}

// 署名検証のロジック
const domain = await getDomain(signer);
const types = {
    Permit: [
        { name: 'owner', type: 'address' },
        { name: 'amount', type: 'uint256' },
        { name: 'nonce', type: 'uint256' }
    ]
};
const value = { owner: '0x...', amount: 100, nonce: 1 };

// 署名の生成(フロントエンド)
const signature = await signer._signTypedData(domain, types, value);

// 署名の検証(バックエンド/スマートコントラクト)
// ここで domain.chainId が現在実行中の環境と一致するか必ず照合すること

バックエンドでの防御:検証プロセスの「守り」

バックエンドで検証を行う際、単に ecrecover でアドレスを復元するだけでは不十分だ。以下のチェックリストを必ず実装してほしい。

1. Nonceの管理: 一度使用した署名は無効化する。これを怠れば、リプレイ攻撃は防げない。
2. ChainIDの照合: 受信したリクエストに含まれる domain パラメータが、現在接続している環境と一致しているか厳密に比較する。
3. 署名有効期限(Expiry)の付与: Permit 構造体に deadline フィールドを追加し、一定時間経過した署名は拒否する実装を標準にする。

Pythonによる検証の断片例

from eth_account.messages import encode_typed_data

# 受信した署名が意図したチェーンIDのものかチェック
def verify_signature(data, signature, expected_chain_id):
    domain = data['domain']
    
    # チェーンIDの不一致は即座に弾く
    if domain['chainId'] != expected_chain_id:
        raise ValueError("Invalid ChainID: Cross-chain replay attack detected.")
    
    # 署名の検証実行
    encoded_msg = encode_typed_data(full_message=data)
    recovered_address = Account.recover_message(encoded_msg, signature=signature)
    return recovered_address

セキュリティリサーチャーからの警告:インフラ側での補強

最後に、コードだけでなくインフラの観点からも一言。攻撃者は脆弱なAPIエンドポイントを叩き続け、署名のパターンを収集する。

  • レートリミットの設定: Nginx 等で、署名検証エンドポイントへの短時間のリクエストを制限せよ。
  • WAFの活用: AWS WAF や Cloudflare を使い、署名データが異常に大きい、あるいは不正なエンコーディングが含まれているリクエストを事前に遮断する設定を入れろ。
# Nginxでのレートリミット例
limit_req_zone $binary_remote_addr zone=sign_limit:10m rate=5r/s;

location /api/v1/verify-signature {
    limit_req zone=sign_limit burst=10 nodelay;
    proxy_pass http://backend_cluster;
}

結びに:セキュリティは「性悪説」で設計せよ

「自分の書いたコードは攻撃される前提」で設計する。これが、私がインシデント現場で学び取った唯一の真理だ。EIP-712は非常に強力なツールだが、それは正しく使えばの話だ。

ドメインセパレータの chainId を疎かにする行為は、玄関の鍵をかけずに外出するのと同じことだ。今日のコードをコピーして終わりにするのではなく、なぜその実装が必要なのか、自分の頭で一度咀嚼してほしい。堅牢なシステムは、細部への執着からしか生まれないのだから。

コメント

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