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

NFTの真正性をデジタル署名で確保:NFTスマートコントラクト開発において、デジタル署名がどのように真正性と完全性を提供するのかを解説します。

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

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

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

図1. OpenSea取引プロセス

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

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

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

3.1. Association NFT

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

3.2. OpenSea

もう一つのセキュリティ問題は、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. 提案

ホートン原則に従い、言われたことではなく、意図したことに署名してください。署名には、網羅的正確な情報が必要です。

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

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

Sign up for the latest updates