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

署名リプレイ攻撃:その「一回限りの約束」をどう守り抜くか

現場でインシデント対応をしていると、開発者が「署名検証さえ実装すれば安全だ」と勘違いしているケースによく遭遇する。だが、署名はあくまで「誰が言ったか」を証明するものであって、「いつ言ったか」を保証するものではない。

特に、Web3のトランザクションや、IoTデバイスからサーバーへ送られる指令において、署名リプレイ攻撃(Replay Attack)は最も古典的でありながら、最も見落とされがちな落とし穴だ。一度送信された有効な署名パケットを傍受し、それをそのまま再送することで、攻撃者は本来の意図とは異なるアクションを何度でも実行できてしまう。

今日は、理論の解説はそこそこに、現場で即座に採用すべき「Nonce(ナンス)管理」の鉄則を叩き込む。

—

なぜ「Nonce」が必要なのか

署名リプレイ攻撃を防ぐための唯一の解は、「一度使った署名は二度と受け付けない」という制約をサーバー側(またはコントラクト側)で強制することだ。ここで登場するのが Nonce(Number used once)だ。

単純なカウンター方式でも良いが、並列処理が発生する分散システムやIoTデバイスの場合、順序保証が難しいため、以下の設計が推奨される。

1. Strict Nonce(カウンター方式): 常に current_nonce + 1 を要求する。厳密だが、ネットワーク遅延でパケット順序が入れ替わると詰む。
2. Bitmap / Set 方式: 使用済みの nonce をデータベースやキャッシュ(Redis等)に保存し、重複チェックを行う。

—

【実装サンプル】Node.js (Express) + Redis での署名検証ロジック

WebアプリやIoTゲートウェイのバックエンドで実装すべき、最も堅牢なパターンの実装だ。署名の検証には ethers.js を想定している。

const { ethers } = require('ethers');
const Redis = require('ioredis');
const redis = new Redis(); // RedisでNonceを管理

/**
 * 署名リプレイ攻撃を防ぐ検証関数
 * @param {string} address - 署名者のアドレス
 * @param {string} message - 送信されたメッセージ
 * @param {string} signature - 署名データ
 * @param {number} nonce - クライアントから送られてきたNonce
 */
async function verifyRequest(address, message, signature, nonce) {
    // 1. RedisからNonceの利用状況を確認
    const key = `nonce:${address}:${nonce}`;
    const isUsed = await redis.get(key);
    
    if (isUsed) {
        throw new Error("この署名は既に利用されています(リプレイ攻撃の可能性)");
    }

    // 2. メッセージの署名者を復元
    const signer = ethers.utils.verifyMessage(message, signature);
    if (signer.toLowerCase() !== address.toLowerCase()) {
        throw new Error("署名が正しくありません");
    }

    // 3. NonceをRedisに保存(有効期限はリクエストの妥当性に合わせる)
    // ここでは1時間の猶予を持たせてロックする
    await redis.set(key, "used", "EX", 3600);
    
    return true;
}

—

【Web3/Solidity】スマートコントラクトでの実装

ブロックチェーン上では、ガス代を節約しつついかに安く検証するかが鍵だ。mappingを使って使用済みNonceを記録するのが定石だ。

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

contract SecureVault {
    // アドレスごとに使用済みのNonceを管理
    mapping(address => mapping(uint256 => bool)) public usedNonces;

    function executeTransaction(
        bytes memory message,
        bytes memory signature,
        uint256 nonce
    ) public {
        address signer = recoverSigner(message, signature);
        
        // 1. Nonceの重複チェック
        require(!usedNonces[signer][nonce], "Nonce already used");
        
        // 2. Nonceをマーク
        usedNonces[signer][nonce] = true;
        
        // 3. ビジネスロジックを実行
        // ... (実行処理)
    }

    // 署名者復元ロジック(ECDSA)
    function recoverSigner(bytes memory message, bytes memory signature) internal pure returns (address) {
        // ecrecoverの実装...
    }
}

—

現場のエンジニアへ:運用時の「盲点」

コードを書いて終わりではない。インシデントハンドリングの視点から、以下の2点だけは必ず運用ルールに組み込んでほしい。

  • Timestampとの併用: nonce だけだと、将来の nonce を先読みされるリスクがある。署名の中に必ず timestamp を含め、サーバー時刻 - timestamp > 5分 のような時間制限を設けること。これにより、古い nonce を永遠に保持し続ける必要がなくなる。
  • Redisの永続化設定: 上記の Redis 例では、再起動時に nonce が消えると攻撃者が「過去の署名」を再送できる可能性が生まれる。重要なトランザクションを扱う場合は、RDBへの書き込みを非同期で行うか、AOF(Append Only File)による永続化を有効にすること。

最後に

セキュリティとは、完璧を目指すことではなく、「攻撃者のコストを最大化し、自社の監視網に引っかかる確率を高めること」だ。

署名検証はフロントエンドからバックエンド、そしてスマートコントラクトまで一貫した設計思想が必要になる。今日紹介した nonce 管理をサボっている箇所がないか、一度コードベースを見直してほしい。それが、明日発生するかもしれない不正アクセスを防ぐ、君の最初の一歩になるはずだ。

コメント

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