Back to Blog

セキュアなスマートコントラクト開発 (2) ― NFT(市場)におけるデジタル署名の活用と正しい使い方

August 12, 2022
7 min read

1. はじめに

デジタル署名は、真正性と完全性を確保するために使用されます。この記事で説明されているように、「有効なデジタル署名は、前提条件が満たされている場合、受信者に、メッセージが既知の送信者によって作成された(真正性)、およびメッセージが転送中に改ざんされていない(完全性)ことを非常に高く確信させます。」

デジタル署名は、スマートコントラクト、例えば、許可リストミントやオーダーブックNFTマーケットプレイスで広く使用されています。これは、トランザクションコストを節約するのに役立つからです(オフチェーン署名とオンチェーン検証)。しかし、開発者による誤用は、NFTマーケットプレイスにもリスクをもたらします。このブログでは、NFTエコシステムにおけるデジタル署名の誤用について説明します。

2. アプリケーション

デジタル署名は、NFTコントラクトでの許可リストミント(有効な署名を持つユーザーのみがNFTをミントできる)、およびオーダーブックNFTマーケットプレイスでのオーダー検証(期待される署名を持つオーダーのみが実行できる)で広く使用されています。データの署名はガスを節約するためにオフチェーンで行われます。以下に、これらの2つの使用シナリオを説明します。

2.1. 許可リストミント

「NFTミント」とは、ブロックチェーン上でNFTを作成する手順です。ほとんどのNFTプロジェクトは、製品を普及させたいと考えており、許可リストミント(プリセールなどとも呼ばれます)によってユーザーを動機付けることを好みます。スポットを獲得した人々は、より低い価格(無料の場合もあります)でトークンをミントできます。デジタル署名は、許可リストミンターと一般(通常の)ミンターを区別するために使用されます。以下は、許可リストミントの実装例です。

function mint_approved(
        vData memory info,
        uint256 number_of_items_requested,
        uint16 _batchNumber
    ) external {
        ...
        require(verify(info), "Unauthorised access secret");
        ...
    }
    function verify(vData memory info) public view returns (bool) {
        require(info.from != address(0), "INVALID_SIGNER");
        bytes memory cat =
            abi.encode(
                info.from,
                info.start,
                info.end,
                info.eth_price,
                info.dust_price,
                info.max_mint,
                info.mint_free
            );
        bytes32 hash = keccak256(cat);
        require(info.signature.length == 65, "Invalid signature length");
        bytes32 sigR;
        bytes32 sigS;
        uint8 sigV;
        bytes memory signature = info.signature;        assembly {
            sigR := mload(add(signature, 0x20))
            sigS := mload(add(signature, 0x40))
            sigV := byte(0, mload(add(signature, 0x60)))
        }        bytes32 data =
            keccak256(
                abi.encodePacked("\x19Ethereum Signed Message:\n32", hash)
            );
        address recovered = ecrecover(data, sigV, sigR, sigS);
        return signer == recovered;
    }

このコードスニペットは、Association NFT脆弱性があります - このコードをコピーしないでください)からのものです。mint_approved()関数は、許可リストミントを実装することを意図しています。プロジェクトオーナーはミントメッセージ(info変数)に署名し、許可されたミンター(NFTをミントできる人)にメッセージを送信します。その後、ミンターは署名された変数を指定してapproved_mintを呼び出すことができます。コントラクトは、メッセージがプロジェクトによって署名されているかどうかを検証します(signer == recovered)。もしそうであれば、関数を呼び出した者はNFTをミントすることを許可されます(ただし、関数を呼び出した人が許可リストに載っている実際の人物であるかどうかの検証がないため、安全ではありません)。

2.2. オーダー検証

オーダー検証は、NFTエコシステムにおけるデジタル署名のもう1つのアプリケーションです。NFTマーケットプレイスは、NFTの取引機能を提供するため、NFTエコシステムにおいて不可欠な役割を果たします。各NFTトークンは非代替性であるため、自動マーケットメーカー(AMM)の取引ポリシーはNFT市場では使用が困難です。そのため、OpenSea、LooksRare、X2Y2などのほとんどのNFTマーケットプレイスは、オーダーブック取引モデルを採用しています。

オーダーブック取引はシンプルです。特定の価格で資産を売りたい人であるメーカーと、売り手の価格で資産を買いたい人であるテイカーがいます。この場合、オーダーが一致します。オーダーブックNFTマーケットプレイスでもプロセスは同じです。唯一の違いは、オーダー提供のプロセスです。NFTマーケットプレイスは、オーダー検証にデジタル署名を使用します。図1は、オーダーブックマーケットプレイスの1つであるOpenSeaの取引プロセス全体の例を示しています。

**図1. OpenSea取引プロセス**
図1. OpenSea取引プロセス

具体的には、売り手は売却オーダーに署名し、OpenSeaのサーバーに保存します。買い手はOpenSeaのサーバーから署名された売却オーダー情報を取得し、署名された売却オーダーをパラメータとしてNFTマーケットコントラクトを呼び出します。マーケットコントラクトは、売り手が売却オーダーに署名したことを確認するためにオーダーを検証します(買い手がトランザクションを開始するため)。これにより、買い手が売り手の同意なしに資産を購入することを防ぎます。

3. セキュリティインシデント

ホートン原則は、暗号システムのマキシムであり、「言われていることではなく、意味されていることを認証する」または「意味したことを署名し、署名したことを意味する」と表現できます。これは、アクション全体を正確に署名することを要求します。署名が部分的または不正確である場合、結果は壊滅的になります。

3.1. Association NFT

セクション2.1のNBA NFTコントラクトを思い出してください。 verify 関数は標準的な署名検証を行いますが、1つの重要なコンポーネントが欠けています。署名検証は、メッセージがプロジェクトによって署名されていることを確認するだけです。しかし、署名を提供する人物と署名されたメッセージ内の許可リストミンターとの一貫性を強制するものがありません。その結果、誰でも同じ署名を使用して検証を通過し、NFTをミントできます。

3.2. OpenSea

もう1つのセキュリティ問題はOpenSeaに関するものです。2022年初頭、研究者たちはNFT取引のコア機能を実装しているOpenSeaマーケットプレイスコントラクト(バージョン:wyvern 2.2)の潜在的な脆弱性を明らかにしました。

Wyvernプロトコルでは、ユーザーはオフチェーンでリスティング(売却オファー)またはオファー(購入オファー)を作成し、オファーの署名はオンチェーンで検証されます。Wyvernオファーは多くのパラメータを含んでおり、パラメータは単一のバイト文字列に集約されてオファーのダイジェストが計算されます。その後、コントラクトはダイジェストの署名を検証します。パラメータの集約方法は、単純にパラメータを以下の方法でバイト文字列にパックします。

index = ArrayUtils.unsafeWriteAddress(index, order.target);
index = ArrayUtils.unsafeWriteUint8(index, uint8(order.howToCall));
index = ArrayUtils.unsafeWriteBytes(index, order.calldata);
index = ArrayUtils.unsafeWriteBytes(index, order.replacementPattern);
index = ArrayUtils.unsafeWriteAddress(index, order.staticTarget);
index = ArrayUtils.unsafeWriteBytes(index, order.staticExtradata);
index = ArrayUtils.unsafeWriteAddress(index, order.paymentToken);

たとえば、パラメータが2つのコンポーネント (address, bytes) で構成され、パラメータが (0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xc098") の場合、集約されたバイトは 0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fc098 となり、単純に address + bytes です。簡単で明確に見えますよね?

次に、より複雑な例を考えてみましょう。パラメータの構造は (address, bytes, bytes) です。

パラメータ1は _(0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xab", "0xcdef")_です。

パラメータ2は _(0x9a534628b4062e123ce7ee2222ec20b86e16ca8f, "0xabcd", "0xef")_です。

集約されたバイトは次のようになります。

パラメータ1: _0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fabcdef_.

パラメータ2: _0x0000000000000000000000009a534628b4062e123ce7ee2222ec20b86e16ca8fabcdef_.

すごい!2つの異なるパラメータが同じ集約結果を持つということは、それらのダイジェストが同じであることを意味し、1つの署名で2つの異なるパラメータを検証できることになります。

これは、パラメータに多くの可変長コンポーネントが含まれているためです。攻撃者は変数の一部を切り取り、切り取った部分を前のコンポーネントまたは後のコンポーネントにアタッチできます。残念ながら、Wyvernコントラクトには次のように多くの可変長パラメータがあります。

......
    address target;
    /* HowToCall. */
    AuthenticatedProxy.HowToCall howToCall;
    /* Calldata. */
    bytes calldata;
    /* Calldata replacement pattern, or an empty byte array for no replacement. */
    bytes replacementPattern;
    /* Static call target, zero-address for no static call. */
    address staticTarget;
    /* Static call extra data. */
    bytes staticExtradata;
    ......

この脆弱性の影響は、攻撃者が(可能であれば)被害者のアカウントを制御して、悪意のある動作を実行できることです。脆弱性の詳細な分析はこちらにあります。

このセクションで言及されている2つのセキュリティインシデントは、どちらもホートン原則に違反しています。具体的には、NBAコントラクトは署名されたメッセージにミンターを含めていない(または署名されたメッセージ内の情報と実際の呼び出し元のとの一貫性をチェックしていない)こと、そしてWyvernコントラクトは構造化されていないパラメータに署名しているため、アクションの意味が、パラメータの表示(言われていること)が変わらないまま変更される可能性があります。

4. 提案

ホートン原則に従い、言われていることではなく、意味したことを署名してください。署名には、包括的正確な情報を含める必要があります。

  • 検証されるべきすべての情報を署名に含めてください。署名されたメッセージ内のデータと実行時の値(例:署名されたメッセージ内の意図されたユーザーと実際のユーザー)の一貫性をチェックしてください。
  • 署名されるメッセージは、決定論的にエンコードされる必要があります。例えば、異なる構造を持つメッセージでも同じエンコード結果になるようなメッセージが存在しないようにしてください。

このシリーズの他の記事を読む