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

署名リプレイ攻撃:その「一回限りの承認」は本当に守られているか?

現場のエンジニア諸君、お疲れ様。今日もどこかの管理画面でログを眺めていることだろう。

さて、今日はWeb3とIoTの交差点で最も頻発し、かつ最も甘く見られがちな「署名リプレイ攻撃(Signature Replay Attack)」について話そう。スマートコントラクトを扱うプロジェクトや、オフチェーン署名で認証を行うIoTゲートウェイを実装しているなら、これは避けて通れない「死線」だ。

1. なぜ「署名」だけでは足りないのか

まず基本を押さえよう。Web3の世界では、秘密鍵で署名を行い、公開鍵で検証する。一見完璧に見えるが、署名データそのものは「ただの文字列」だ。一度サーバーやブロックチェーンに送信された署名を、攻撃者が傍受して再送したらどうなるか?

サーバー側が「この署名は正しい形式だ」としか判断していなければ、攻撃者は全く同じ署名を何度も送りつけ、何度もトランザクションを通すことができる。これがリプレイ攻撃だ。銀行の振込依頼書をコピーして何度も提出するようなものだと言えば、その恐ろしさがわかるだろう。

2. なぜ「EIP-712」なのか

昔のスマートコントラクトは、生データのハッシュ値に署名していたが、これでは「どのコントラクトに対する署名なのか」というコンテキストが欠けていた。そこで登場したのが EIP-712 だ。

EIP-712は、ドメイン分離(Domain Separator)という概念を導入した。これにより、署名データに「どこのネットワークか(ChainID)」「どのコントラクトか(Contract Address)」という紐付けが可能になり、誤操作や他環境へのリプレイを防ぐ構造になっている。だが、これだけではまだ足りない。最後に必要なのが「Nonce(ナンス)」だ。

3. 実践:Nonce管理による鉄壁の防御

リプレイ攻撃を完全に無効化する唯一の解は、「一度使った署名は二度と受け付けない」という状態をサーバー側で管理することだ。

以下に、Python(Flask)とWeb3.pyを用いた、Nonce管理付き署名検証のセキュアな実装例を示す。

from web3 import Web3
from eth_account.messages import encode_defunct

# ユーザーごとにNonceを管理するDBがある想定
# 実際にはRedisやPostgreSQLで管理すること
user_nonces = {"0xUserAddress...": 1}

def verify_and_process(user_address, signature, message_data, user_nonce):
    # 1. Nonceの整合性チェック(これが防波堤となる)
    if user_nonce != user_nonces.get(user_address):
        return {"status": "error", "message": "Invalid or Replayed Nonce"}

    # 2. 署名検証
    message = encode_defunct(text=message_data)
    recovered_address = Web3().eth.account.recover_message(message, signature=signature)

    if recovered_address.lower() == user_address.lower():
        # 3. 成功したらNonceをインクリメント(使い回しを物理的に不可能にする)
        user_nonces[user_address] += 1
        return {"status": "success", "message": "Transaction Executed"}
    
    return {"status": "error", "message": "Signature Mismatch"}

4. フロントエンドでの実装ルール(JavaScript)

フロントエンド側では、毎回最新のNonceをサーバーから取得して署名に組み込む必要がある。

async function signMessage(messageData) {
    // 1. サーバーから現在のNonceを取得
    const response = await fetch('/api/get-nonce');
    const { nonce } = await response.json();

    // 2. メッセージにNonceを含めて署名させる
    const payload = JSON.stringify({ data: messageData, nonce: nonce });
    const signature = await ethereum.request({
        method: 'personal_sign',
        params: [payload, account],
    });

    // 3. 署名とNonceをサーバーへ送る
    await fetch('/api/execute', {
        method: 'POST',
        body: JSON.stringify({ signature, nonce, messageData })
    });
}

5. セキュリティチーフからの「泥臭い」アドバイス

コードが書けただけでは、まだ安心は早い。以下のポイントを運用ルールとして叩き込んでくれ。

  • タイムスタンプの併用: Nonce管理に加えて、署名の有効期限(Deadline)を設けること。EIP-712の構造内に deadline フィールドを入れ、期限が切れた署名は弾くようにする。
  • WAFの役割: もし大量のリプレイ攻撃を検知したら、その送信元IPを自動的にブロックする設定(Nginxの limit_req やAWS WAFのレートベースルール)を忘れずに入れること。
  • Nonceの漏洩を防ぐ: Nonceはクライアントサイドで推測可能な値にしてはならない。必ずサーバー側が発行した「現在の正しいNonce」を使用させるフローを徹底しろ。

最後に一つ。セキュリティとは「完璧なコード」を書くことではなく、「攻撃者がコストを支払いたくない構造」を作ることだ。Nonceを管理し、署名のコンテキストを限定するだけで、攻撃者のモチベーションは一気に削がれる。

君たちの設計が、次のインシデントを防ぐ防波堤になることを期待している。何かあればまた聞きに来い。現場からは以上だ。

コメント

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