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

こんにちは!Web3やIoTのセキュリティの世界へようこそ。
ブロックチェーンやスマートコントラクトの開発に足を踏み入れたばかりの頃は、覚えることがたくさんあって大変ですよね。「なんだか難しそうな用語ばかりだな…」と不安になるかもしれませんが、安心してください。一歩ずつ、身近な例えから紐解いていけば、誰でもしっかりと安全なコードを書けるようになりますよ。

今回は、スマートコントラクトのセキュリティにおいて非常に重要かつ、初心者がやりがちな落とし穴である「署名リプレイ攻撃(Signature Replay)」とその防止策について、お話ししていきますね。

—

1. 家の鍵のコピーと「使い回し」の恐怖

まずは、身近な防犯の仕組みから考えてみましょう。

皆さんは、自宅の玄関の鍵を開けるとき、どうしていますか?もちろん、自分の持っている物理的な鍵を鍵穴に差し込んで回しますよね。では、もし「合鍵を勝手に何個もコピーされて、泥棒が何度も同じ鍵を使ってあなたの家に自由に出入りできてしまったら」……想像するだけでも恐ろしい話です。

ブロックチェーンの世界でも、これと全く同じことが起こり得ます。
Web3のアプリ(DApps)では、ユーザーが自分のウォレットを使って「メッセージにデジタル署名(=電子的な鍵)」をすることで、「私がこの取引を承認しました」という証明を行います。

もし、この署名データを悪意のある攻撃者に盗み見られてしまったらどうなるでしょうか?
攻撃者がその署名をそっくりそのまま別の場所や、別のタイミングで「再利用(リプレイ)」できてしまったら、あなたの資産が勝手に何度も引き出されてしまうかもしれないのです。これが「署名リプレイ攻撃」の正体です。

—

2. なぜ署名は盗まれて再利用されてしまうのか?

もう少し技術的な仕組みを覗いてみましょう。

スマートコントラクトの世界では、ユーザーがオフチェーン(ブロックチェーンの外)で作成した署名を、コントラクトに送信して処理を実行させることがよくあります。ガス代(手数料)を節約するためだったり、メタトランザクション(代払いの仕組み)を実現するためです。

ここで、もし署名データの中に「今、何回目の取引か」や「どのブロックチェーンネットワーク上のものか」という情報が含まれていなかったらどうなるでしょうか?

攻撃者は、あなたが一度だけ許可したはずの署名データをブロックチェーンのネットワーク上からそっと拾い上げ、全く同じデータを何度も何度もスマートコントラクトに送りつけます。コントラクト側は、「お、正しいユーザーの署名だな!」と勘違いして、毎回処理を実行してしまうわけです。

まさに、一度使ったはずの電車の切符を、何度も改札に通してタダ乗りされてしまうような状態ですね。これを防ぐのが、今回学ぶ「nonce(ナンス)」と「チェーンID」の導入です。

—

3. 防御の切り札:nonce(ナンス)とチェーンID

この恐ろしいリプレイ攻撃を防ぐために、私たちは署名の中に特別な「歯止め」を仕込みます。それが次の2つの要素です。

1. nonce(Number used once:一度だけ使われる数字)
ユーザーごとの取引カウンターです。取引が行われるたびに 1, 2, 3……と必ず数字がカウントアップされていきます。もし、すでに使われた古い nonce を持った署名が来たら、スマートコントラクトは「あ、これもう使われた古い鍵だね」と見抜いて、一蹴して弾き返します。
2. チェーンID(Chain ID)
ブロックチェーンの「住所(ネットワーク番号)」のようなものです。例えば、テスト用のネットワーク(Ethereum Sepoliaなど)と本番用のネットワーク(Ethereumメインネットなど)ではIDが異なります。これを含めることで、「テストネット用の署名を、本番ネットで悪用する」といったクロスチェーンの使い回しを完全に防ぐことができます。

—

4. 実装パターンを見てみよう(Solidityコード例)

それでは、実際に安全なスマートコントラクトがどのように書かれているのか、Solidityのコードを見てみましょう。難しく見えるかもしれませんが、日本語のコメントを追いながら一緒に見ていけば大丈夫です。

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

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

contract SecureSignatureExample {
    using ECDSA for bytes32;

    // ユーザーごとのnonce(取引回数)を記録するマッピング
    // 例: nonces[userAddress] = 5
    mapping(address => uint256) public nonces;

    // 署名を使って何か処理を行う関数
    function executeWithSignature(
        uint256 amount,
        uint256 nonce,
        bytes memory signature
    ) public {
        // 1. ユーザーの現在のnonceと、引数で受け取ったnonceが一致しているかチェック!
        // これにより、古い署名の使い回し(リプレイ攻撃)を防ぎます。
        require(nonce == nonces[msg.sender], "Invalid nonce: 署名が古く無効です");

        // 2. nonceをインクリメント(+1)し、この署名が「二度と使えない」ようにする
        nonces[msg.sender]++;

        // 3. 署名されたメッセージのハッシュを組み立てる
        // ここで msg.sender, amount, nonce, そして block.chainid(チェーンID)を混ぜ合わせるのがポイント!
        bytes32 messageHash = keccak256(
            abi.encodePacked(msg.sender, amount, nonce, block.chainid, address(this))
        );

        // プレフィックスを付与してイーサリアム標準の署名形式に変換
        bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(messageHash);

        // 4. 署名した本人が本当に msg.sender なのかを検証する
        address signer = ethSignedMessageHash.recover(signature);
        require(signer == msg.sender, "Invalid signature: 署名者が一致しません");

        // --- ここに実際の処理(トークンの転送やデータの更新など)を書く ---
    }
}

コードのポイント解説

  • require(nonce == nonces[msg.sender]): ここで「今まさに使える正しい順番の鍵か?」をチェックしています。
  • nonces[msg.sender]++: 処理の直後にカウンターを1つ進めることで、同じ nonce では二度と関数を通過できなくしています。
  • abi.encodePacked(..., block.chainid, ...): ここに block.chainid を含めることで、万が一ネットワークが変わった際にも不正な流用ができなくなるようガチガチに固めています。

—

5. まとめと一歩ずつ進むあなたへ

今回は、署名リプレイ攻撃の仕組みと、それを防ぐための nonce やチェーンIDの重要性について解説しました。

  • 攻撃の仕組み: 一度作成された有効な署名が盗まれ、何度も悪用されてしまう。
  • 防御の要: 取引ごとに変わる nonce で「使い回し」をブロックし、チェーンID で「異なるネットワークでの悪用」を防ぐ。

セキュリティの対策と聞くと、なんだか壁が高く感じられるかもしれませんが、基本の原理は「合鍵の使い回しを防ぐための整理整頓」と同じです。実務でコードを書く際や、新しいシステムを設計する際には、「この署名は、別の場所や別のタイミングで何度も使われてしまわないか?」という視点を常に持つように心がけてみてくださいね。

一歩ずつ、確実に安全なコードを書けるエンジニアになっていきましょう!応援しています!

コメント

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