【実務・中級編】 NFTマーケットプレイスの注文キャンセル不備 – IoT・OT(制御システム) & ブロックチェーンセキュリティ防御ガイド

NFTマーケットプレイスの死角:なぜ「署名済みオフチェーン注文」のキャンセルは実装を誤るのか

おい、ちょっと手を止めてくれ。最近、とある新進気鋭のNFTマーケットプレイスの監査に入ったんだが、まあ見事にやらかしてくれていたよ。
「オフチェーンでEIP-712の署名を集めて、ガス代を節約するためにオンチェーンでのキャンセルはユーザーにトランザクションを送らせず、データベースのフラグを立てるだけで済ませました」……ってな。

エンジニアとしては「スマートなアーキテクチャだね」なんてドヤ顔したくなるかもしれないが、セキュリティリサーチャーの視点から言わせてもらうと、それは「ドロボウに勝手口の鍵を渡しっぱなしにしているようなもの」だ。

今回は、このNFTマーケットプレイスにおける「注文キャンセル不備(Nonce管理の欠落)」という、Web3特有でありながらWeb2的なデータベース設計の甘さが招く致命的な脆弱性について、実際の攻撃シナリオと、明日から即座にプロダクション環境へ投入できるセキュアな実装コードベースで徹底的に解説していく。

—

1. 脆弱性のメカニズム:なぜ「古い注文」が蘇るのか?

現代のNFTマーケットプレイスの多くは、トランザクション手数料(ガス代)の高騰を抑えるために「オフチェーン注文(Off-chain Order)」を採用している。ユーザー(Maker)は自身のウォレット(MetaMaskなど)で「このNFTをこの価格で売ります」というメッセージに暗号学的署名(Personal Sign または EIP-712)を行い、その署名データと注文情報をマーケットプレイスのサーバー(DB)に保存する。

購入者(Taker)が現れたら、サーバーはその署名の正当性を検証し、スマートコントラクトの atomicMatch などの関数を叩いてアトミックにトレードを成立させる。

ここで問題になるのが、「出品者が注文をキャンセルしたときの処理」だ。

現場でやりがちなアンチパターン

「よし、キャンセルボタンが押されたから、データベースの orders テーブルの status カラムを cancelled にアップデートしておこう!」

……これの何が問題か分かるか?
ブロックチェーンの世界では、「過去にユーザーが生成した有効な署名(暗号学的証明)そのものは、ブロックチェーン上に記録されない限り、消滅しない」という残酷な真実がある。

もし、サーバー側のDB設計で「注文ID(またはハッシュ)」しか管理しておらず、ユーザーごとの nonce(インクリメントされるカウンター)や、取り消し済みの注文ハッシュのブラックリストをスマートコントラクト側、あるいは厳密なオフチェーン検証ロジックで同期していなかったらどうなるか?

攻撃者は、一度キャンセルした(あるいは過去に売却済みの)古い注文の署名データを手元に保持し続け、スマートコントラクトの関数を直接叩くか、あるいは脆弱なAPIエンドポイントを悪用して、「意図しないタイミングでその注文を強制復活(リプレイ)」させることができる。マーケットプレイスのDBが status: cancelled になっていても、スマートコントラクト側がその署名を有効と判定してしまえば、底値で売りに出したはずのNFTがゴッソリ抜き取られる大惨事につながるのだ。

—

2. 攻撃シミュレーション:悪意あるTakerの動き

実際のインシデント現場を想像してほしい。

1. 初期状態: アリスが自身の貴重なPFP NFTを 1 ETH でオフチェーン出品した。
2. 心変わり: アリスはマーケットプレイスのUIから「キャンセル」ボタンを押した。サーバー側では UPDATE orders SET status = 'cancelled' WHERE id = 123; が実行された。アリスは「これで安心」とブラウザを閉じた。
3. 攻撃者の嗅覚: ボブ(攻撃者)は、アリスが注文を出す瞬間のリクエストと、対応するEIP-712の署名データをあらかじめパケットキャプチャまたはAPIのログから取得(あるいは過去の公開ログから収集)していた。
4. リプレイ攻撃の実行: ボブは、マーケットプレイスの正規の購入APIを経由せず、あるいはバックエンドのバリデーション不備を突いて、その「過去の署名データ」をスマートコントラクトの決済関数に直接流し込んだ。
5. 結末: マーケットプレイスのコントラクトは「署名は有効で、期限も切れておらず、コントラクト側でこの注文ハッシュはまだ消費されていない」と判断し、アリスの意図に反してトレードが成立。アリスのNFTはボブのウォレットに移動した。

これが、Nonce管理とスマートコントラクトのステート検証をサボった代償だ。

—

3. 完全に防御するための実装アプローチ

この脆弱性を完全に叩き潰すためには、以下の2つの防衛ラインを構築する必要がある。

1. スマートコントラクト側でのNonce管理: ユーザーごとに nonces[user_address] を持ち、注文ごとに特定のNonceを要求する。キャンセル時は、このNonceの値をインクリメントすることで、それ以前に生成されたすべての古い注文署名を一網打尽で無効化(invalidate)する。
2. バックエンド(API)での厳密なステートチェック: サーバー側でも単なるフラグ管理だけでなく、現在のユーザーのNonceと注文データの整合性を毎回検証する。

—

4. セキュアな実装サンプルコード

後輩の君たちに渡す、実務でそのまま使えるコードだ。今回は、最も堅牢とされる EIP-712を用いたNonceベースのスマートコントラクト(Solidity) と、それを検証・処理する Node.js (JavaScript) のバックエンドロジック のサンプルを用意した。

A. Solidity スマートコントラクト(一部抜粋)

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

import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SecureNFTMarketplace is EIP712, ReentrancyGuard {
    using ECDSA for bytes32;

    // ユーザーごとのNonce管理(キャンセル用)
    // user_address => nonce_number
    mapping(address => uint256) public nonces;

    // 注文がすでに実行されたかどうかを記録するハッシュのストア
    mapping(bytes32 => bool) public executedOrders;

    bytes32 private constant ORDER_TYPEHASH = keccak256(
        "Order(address maker,address nftContract,uint256 tokenId,uint256 price,uint256 nonce,uint256 deadline)"
    );

    struct Order {
        address maker;
        address nftContract;
        uint256 tokenId;
        uint256 price;
        uint256 nonce;
        uint256 deadline;
    }

    event OrderCancelled(address indexed maker, uint256 indexed newNonce);
    event OrderExecuted(bytes32 indexed orderHash, address indexed taker);

    constructor() EIP712("SecureNFTMarketplace", "1.0") {}

    /**
     * @notice ユーザーが自身のNonceをインクリメントし、過去の注文をすべて無効化する
     */
    function cancelAllOrders() external {
        nonces[msg.sender]++;
        emit OrderCancelled(msg.sender, nonces[msg.sender]);
    }

    /**
     * @notice 注文の実行と署名検証
     */
    function executeOrder(
        Order calldata order,
        bytes calldata signature
    ) external payable nonReentrant {
        // 1. デッドラインの検証
        require(block.timestamp <= order.deadline, "Order expired");

        // 2. ユーザーの現在のNonceと一致しているか検証(古い注文の排除)
        require(order.nonce == nonces[order.maker], "Invalid nonce: Order is cancelled or outdated");

        // 3. 注文ハッシュの重複実行防止
        bytes32 orderHash = _hashTypedDataV4(keccak256(abi.encode(
            ORDER_TYPEHASH,
            order.maker,
            order.nftContract,
            order.tokenId,
            order.price,
            order.nonce,
            order.deadline
        )));

        require(!executedOrders[orderHash], "Order already executed");
        executedOrders[orderHash] = true;

        // 4. 署名の検証
        address signer = orderHash.recover(signature);
        require(signer == order.maker, "Invalid signature");

        // 5. 決済処理(ETHの送金とNFTの転送ロジックがここに続く)
        // 省略: 実際のトランスファー処理
        
        emit OrderExecuted(orderHash, msg.sender);
    }
}

B. バックエンド(Node.js / Express)でのキャンセル処理実装

データベース側(PostgreSQL等)でキャンセルを受け付ける際、単なるフラグ更新だけでなく、ブロックチェーン上のトランザクションをトリガーするか、あるいはオフチェーン署名管理DB側でもNonceの不一致を弾くバリデーションを組み込む必要がある。

const { ethers } = require("ethers");

/**
 * @/**
 * @notice 注文キャンセルのAPIエンドポイントのコントローラー例
 * @param {Object} req - Expressリクエストオブジェクト
 * @param {Object} res - Expressレスポンスオブジェクト
 */
async function handleCancelOrder(req, res) {
    const { makerAddress, signature, orderNonce } = req.body;

    try {
        // セキュリティチーフからの鉄則:
        // バックエンド側でも、スマートコントラクトの状態と同期したバリデーションを行う。
        // ユーザーが「全キャンセル」を実行した場合、DB上の該当ユーザーの未約定注文を一括で無効化する。

        // 1. リクエスト元のウォレットアドレスと署名の正当性検証(オプションだが推奨)
        const message = `Cancel all active orders with nonce: ${orderNonce}`;
        const recoveredAddress = ethers.verifyMessage(message, signature);

        if (recoveredAddress.toLowerCase() !== makerAddress.toLowerCase()) {
            return res.status(401).json({ error: "Unauthorized: Signature mismatch" });
        }

        // 2. データベーストランザクションの開始
        // ここでは擬似的なDB操作を記述
        // await db.query('BEGIN');

        // 3. 該当ユーザーのステータスを更新し、指定されたNonce未満の注文をすべて無効ステータスに変更
        const updateQuery = `
            UPDATE nft_orders 
            SET status = 'CANCELLED' 
            WHERE maker_address = $1 AND nonce <= $2 AND status = 'ACTIVE';
        `;
        
        // 実際のプロダクション環境ではここでDBクエリを実行
        // await db.query(updateQuery, [makerAddress, orderNonce]);

        // await db.query('COMMIT');

        return res.status(200).json({ 
            success: true, 
            message: "Orders successfully invalidated." 
        });

    } catch (error) {
        // await db.query('ROLLBACK');
        console.error("Cancellation error:", error);
        return res.status(500).json({ error: "Internal server error during cancellation." });
    }
}

module.exports = { handleCancelOrder };

—

5. 現場のエンジニアへ送るセキュリティチェックリスト

最後に、今日から君たちのプロジェクトですぐに確認してほしいチェックリストを置いておく。コードレビューの際には必ずこれらを指差し確認してくれ。

1. オフチェーン注文にNonceは含まれているか?

  • 注文データ構造体の中に uint256 nonce が存在し、それがユーザーごとに一意に管理されているか確認する。

2. スマートコントラクト側で nonce == nonces[maker] の検証を行っているか?

  • DBのフラグだけで安心してはいけない。スマートコントラクトの実行関数内で、現在のオンチェーンのNonceと照合しているか必ずチェックする。

3. キャンセル時に一括無効化(Lazy Invalidation)の仕組みがあるか?

  • 個別の注文ごとにキャンセル トランザクションを強制するとユーザーの負担が大きすぎるため、Nonceをインクリメントする方式(cancelAllOrders)を採用し、ガス代を抑えつつ安全性を担保しているか。

4. リプレイ攻撃(Replay Attack)対策としての注文ハッシュ記録漏れはないか?

  • executedOrders[orderHash] = true のような二重実行防止策が確実に実装されているか再確認する。

Web3のセキュリティは、一度スマートコントラクトをデプロイしてしまえば、バグの修正には莫大なコストと信頼の失墜が伴う。。「動けばいいや」の精神は今すぐ捨てて、堅牢なステート管理と暗号学的検証を泥臭く徹底しよう。頼んだぞ!

コメント

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