署名リプレイ攻撃:なぜ君の「署名検証」はザルなのか
現場でインシデント対応をしていると、「電子署名を入れたから安全だ」と信じ込んでいる設計に何度も遭遇する。だが、残念ながら署名検証は「署名が正しいこと」を証明するだけで、「それが今まさに送られたリクエストであること」を保証してはいない。
攻撃者は、盗聴した署名パケットをそのまま再送する。これが「署名リプレイ攻撃」だ。今日は、IoTデバイスの認証やスマートコントラクトのオフチェーン署名において、この「使い回し」をどう物理的に遮断するか、泥臭い実装レベルで解説する。
—
1. なぜ「署名」だけでは足りないのか?(PoCのリスク)
想像してほしい。君が管理する工場内のIoTゲートウェイに対し、管理者が「機器の再起動」を指示する署名済みコマンドを発行したとする。
攻撃者は、ネットワークのどこかでこのパケットをキャプチャし、まったく同じ内容を数秒後に再送する。ゲートウェイ側で署名を検証すると、「あ、確かに管理者本人の公開鍵で検証成功だ。実行しよう」となる。これが100回繰り返されたらどうなるか?君の工場は停止と再起動を無限ループし、生産ラインは崩壊する。
この攻撃の肝は、「署名の中身(メッセージ)が変わっていない」ことだ。これを防ぐには、メッセージに「一度しか使えない値」を混ぜ込むしかない。
—
2. 決定的な防衛策:Nonce(ナンス)とChainIDの注入
防衛の基本原則は以下の2点に集約される。
1. Nonce(Number used once): リクエストごとにインクリメントする値を署名対象に含める。サーバー側は「最後に処理したNonce」をDBで保持し、それより小さいか等しい値が来たら即座に破棄する。
2. ChainID / DomainID: 開発環境(Staging)の署名を本番環境(Production)で使えないようにする。これを忘れると、テスト用の署名が本番環境で悪用されるという「クロスチェーン・リプレイ」の餌食になる。
—
3. 実装サンプル:Node.js + ethers.js での堅牢な設計
スマートコントラクトやWeb3バックエンドでよく使われる、EIP-712を模したセキュアな署名検証の実装例だ。
const { ethers } = require("ethers");
/**
* 署名検証ロジック
* @param {string} userAddress - 署名したユーザーの公開鍵アドレス
* @param {string} signature - 受信した署名データ
* @param {number} nonce - クライアントから送られてきたNonce
* @param {string} message - 実行内容
*/
async function verifyRequest(userAddress, signature, nonce, message) {
// 1. DBからユーザーの「現在の期待Nonce」を取得
const expectedNonce = await db.getNonce(userAddress);
// 2. Nonceの検証(リプレイ防止の核心)
if (nonce !== expectedNonce) {
throw new Error("Invalid Nonce: リプレイ攻撃の疑いがあります");
}
// 3. ChainIDを混ぜて、環境外での署名利用を禁止
const chainId = 1; // 本番環境のID
const domain = "Secure-IoT-Gateway-v1";
// 署名対象のデータ構造(これ全体をハッシュ化して署名させる)
const messageHash = ethers.utils.solidityKeccak256(
["string", "uint256", "uint256", "string"],
[domain, chainId, nonce, message]
);
// 署名者アドレスの復元
const signer = ethers.utils.verifyMessage(ethers.utils.arrayify(messageHash), signature);
// 4. アドレスの一致確認
if (signer.toLowerCase() !== userAddress.toLowerCase()) {
throw new Error("署名が不正です");
}
// 5. 処理成功後に必ずNonceを更新(これを忘れるとリプレイし放題になる)
await db.incrementNonce(userAddress);
return true;
}
—
4. インフラ側での防衛:WAFでできること
アプリケーション層でのロジックが完璧でも、脆弱なライブラリや設定ミスが足元をすくう。Nginxなどのゲートウェイ層でも、「明らかに異常なリクエスト」は落とすべきだ。
Nginxでのレート制限設定(/etc/nginx/conf.d/security.conf):
特定のIPから短時間に同じ署名リクエストが大量に飛んでくる場合、論理検証を待たずにIP単位で遮断する。
# IPアドレスごとに1分間あたり10リクエストまで許可
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/m;
server {
location /api/v1/execute {
limit_req zone=api_limit burst=5 nodelay;
# ここでバックエンドの検証ロジックへプロキシ
}
}
—
現場のエンジニアへ:最後に伝えたいこと
署名リプレイ攻撃の恐ろしさは、「システムとしては正しく動いているように見える」ことにある。ログを見てもエラーは出ないし、正しい権限で処理が行われているように記録される。
セキュリティの極意は、「自分たちのコードは必ず攻撃される」と性悪説で考えることだ。もし君が現在、Nonce管理を実装していないのなら、今すぐバックエンドのデータベースに nonce カラムを追加し、署名検証フローを書き直すことを強く推奨する。
後回しにしていい技術的負債はない。特に、IoTのような「一度設置したら修正が困難なデバイス」においては、この実装漏れが将来的な致命傷になる。準備ができたら、まずはPoCコードで今の環境がリプレイに耐えられるか試してみてくれ。健闘を祈る。
コメント