見出し画像

OpenSeaのフィッシングを理解しよう

2022年2月19日に起きた、OpenSeaのフィッシングに関する解説です。

複数のアドレスから高額NFTであるBAYCやAzukiなどが盗まれました。

下記のetherscanから詳細を見ることができます。

https://etherscan.io/address/0xa2c0946ad444dccf990394c5cbe019a858a945bd

全容を理解するには、OpenSeaの仕組みとSolidityの知識が必要かもしれません。
この記事を読んでおくといいかも?です。

*Top絵はFishingですが、Phishingです

何が起きたのか

攻撃者は、OpenSeaの公式のメールを語り、ユーザーに対して偽のOrderに署名させました。

それにより、攻撃者が作成した偽のContractをAthenticatedProxyに実行させました。

その偽のContractはAthenticatedProxyにsetApproveForAllされているERC721をすべてtrasferFromし、攻撃者のアドレスへ移しました。

雑に図解するとこんな感じです。

雑な図解

なぜそんなことができたのか?

詳細を見ていきましょう。

OpenSeaの売買おさらい

オフチェーン Order book

OpenSeaでは、売り手によって署名されたOrderをオフチェーンのorder bookに保存します。
買い手はその売り手の署名済みOrderと自分の買いOrderを元にトランザクションを生成し、OpenSeaのExchange ContractのautomicMatch_ functionを呼び出します。

AuthenticatedProxy

AuthenticatedProxyは、各ユーザーのEOAに対して1つのContractが生成されます。(下記コード)
生成されたAuthenticatedProxyはProxyRegistryの中でEOAとmappingされます。

Proxy Registry:    
   /**
     * Register a proxy contract with this registry
     *
     * @dev Must be called by the user which the proxy is for, creates a new AuthenticatedProxy
     * @return New AuthenticatedProxy contract
     */
    function registerProxy()
        public
        returns (OwnableDelegateProxy proxy)
    {
        require(proxies[msg.sender] == address(0));
        proxy = new OwnableDelegateProxy(msg.sender, delegateProxyImplementation, abi.encodeWithSignature("initialize(address,address)", msg.sender, address(this)));
        proxies[msg.sender] = proxy;
        return proxy;
    }

OpenSeaの売り手は、このAuthenticatedProxyにたいして、ERC721 ContractのsetApproveForAllを呼び出します。
それにより、AuthenticatedProxyはそのERC721 Contractに対してtransferFromを実行する権限を得ます。

この仕組みを利用してOpenSeaはERC721の移動を実施します。

AuthenticatedProxyはproxy functionにより別ContractをdelegateCallすることができますが、それをcallできるのはExchange ContractとmappingされたEOAの持ち主のみです。

AuthenticatedProxy:

function proxy(address dest, HowToCall howToCall, bytes calldata)
        public
        returns (bool result)
    {
        require(msg.sender == user || (!revoked && registry.contracts(msg.sender)));
        if (howToCall == HowToCall.Call) {
            result = dest.call(calldata);
        } else if (howToCall == HowToCall.DelegateCall) {
            result = dest.delegatecall(calldata);
        }
        return result;
    }


正常な購入トランザクション

まずは正常な購入のトランザクションを見ていきましょう

下記はOpenSeaでBAYCを購入した、正規のトランザクションです。

https://etherscan.io/tx/0x5f911bd7406fab0b1e7b4461bc361d75055d1d323be95a73f6520d07dd86d2de

ExchangeにたいしてFunction: atomicMatch_をcallしているのがわかるかと思います。

注目すべきはinput dataのaddrscalldataSellです。

addrs
0x7Be8076f4EA4A4AD08075C2508e481d6C946D12b
0x7BeF8662356116cb436429F47e53322B711F4E42

0xBAf2127B49fC93CbcA6269FAdE0F7F31dF4c88a7
0x0000000000000000000000000000000000000000
0x0000000000000000000000000000000000000000

calldataSell (Decode済)

{
  "name": "matchERC721UsingCriteria",
  "params": [
    {
      "name": "from",
      "value": "0xe1aeacb5f91a2938ac9474fd880ec1542047b333",
      "type": "address"
    },
    {
      "name": "to",
      "value": "0x0000000000000000000000000000000000000000",
      "type": "address"
    },
    {
      "name": "token",
      "value": "0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d",
      "type": "address"
    },
    {
      "name": "tokenId",
      "value": "6921",
      "type": "uint256"
    },
    {
      "name": "root",
      "value": "0x0000000000000000000000000000000000000000000000000000000000000000",
      "type": "bytes32"
    },
    {
      "name": "proof",
      "value": [],
      "type": "bytes32[]"
    }
  ]
}



OpenSeaのatomicMatchを見てみると、下記のように、AuthenticatedProxyは、sell.targetのContractをsell.calldataで呼び出します。

コードの詳細は割愛しますが、sell.targetは上記の太字のaddrs[12]になり、sell.calldataはcalldataSellになります。

ExchangeCore.atomicMatch:       
    /* Access the passthrough AuthenticatedProxy. */
        AuthenticatedProxy proxy = AuthenticatedProxy(delegateProxy);
             
        /* Execute specified call through proxy. */
        require(proxy.proxy(sell.target, sell.howToCall, sell.calldata));

実際に正規のトランザクションのaddrs[11]はOpenSeaのMerkleValidatorのContract Addressであり、calldataでmatchERC721UsingCriteriaをdelegateCallしています。

MerkleValidator
https://etherscan.io/address/0xBAf2127B49fC93CbcA6269FAdE0F7F31dF4c88a7#code

実際には下記コードにある通り、MerkleValidator.matchERC721UsingCriteriaがERC721のtrasferFromを呼び出します。


   function matchERC721UsingCriteria(
        address from,
        address to,
        IERC721 token,
        uint256 tokenId,
        bytes32 root,
        bytes32[] calldata proof
    ) external returns (bool) {
    	// Proof verification is performed when there's a non-zero root.
    	if (root != bytes32(0)) {
    		_verifyProof(tokenId, root, proof);
    	} else if (proof.length != 0) {
    		// A root of zero should never have a proof.
    		revert UnnecessaryProof();
    	}

    	// Transfer the token.
        token.transferFrom(from, to, tokenId);

        return true;
    }

図にするとこんな感じです。

ポイントはトランザクションのinput dataからdelegateCall先を取得しているというところです。

フィッシングのトランザクション

では反対にフィッシングしたトランザクションを見てみましょう

https://etherscan.io/tx/0x9ce04d64310e40091c49c53bac83e5c781b3046e53c256f76daf0e8a73458dad

フィッシングのトランザクションもExchangeのatomicMatch_をCallしています。
しかし、AuthenticatedProxyは偽のContractを呼んでいるのが見てとれます。

atomicMatch_のinput dataを見てみましょう

addrs[11]はOpenSeaのMerkleValidatorのアドレスではなく、フィッシングのために作られた偽のContractのアドレスです。(もちろん、sellCallDataも偽)

つまり、下記のコードのsell.targetは偽Contractとなり、AuthenticatedProxyは偽のContractdelegateCallします。

        /* Access the passthrough AuthenticatedProxy. */
        AuthenticatedProxy proxy = AuthenticatedProxy(delegateProxy);
             
        /* Execute specified call through proxy. */
        require(proxy.proxy(sell.target, sell.howToCall, sell.calldata));

そのため、偽ContractはAuthenticatedProxyにsetApproveForAllされているすべてのERC721をtransferFromする権限を持ちます。

後はContract上で権限を持っているERC721たちをいくらでも何個でも操作できます。やりたい放題です。

署名の取得、検証の突破

実際にAuthenticatedProxyに偽ContractをdelegateCallさせるためには、以下の署名の検証を突破する必要があります。

アルゴリズム上署名の偽装は不可能なので、フィッシング以外ではこれを突破する術はありません。

Exchange.atomicMatch:

        /* Ensure buy order validity and calculate hash if necessary. */
        bytes32 buyHash;
        if (buy.maker == msg.sender) {
            require(validateOrderParameters(buy));
        } else {
            buyHash = requireValidOrder(buy, buySig);
        }

        /* Ensure sell order validity and calculate hash if necessary. */
        bytes32 sellHash;
        if (sell.maker == msg.sender) {
            require(validateOrderParameters(sell));
        } else {
            sellHash = requireValidOrder(sell, sellSig);
        }

フィッシングトランザクションでは、msg.sender=呼び出し元の偽Contract(0x3E0DeFb880cd8e163baD68ABe66437f99A7A8A74)であり、buy.maker=偽Contractとなります。
そのため、一つ目のbuyOrderの署名の検証はされず、2つ目のsell Orderの検証が実施されます。

なので、偽のSell Orderに対して対象ユーザーの署名を1つ入手する必要があります。

そこでOpenSeaのメールアドレスを偽装し、ユーザーにメールを送信し、署名を要求しました。

正常なOrderの署名の場合、下記のようにtargetがMerkleValidatorのアドレスとなります。

一方、偽のOrderの署名の場合、この部分がdelegateCallされる偽のHelper Contract(0xa2c0946aD444DCCf990394C5cBe019a858A945bD)となっています。

このMetamaskのSignature Requestだけから偽のOrderの署名だと見抜くのは非常に困難です。

設計上の問題は?

署名さえあれば、setApproveForAllされたAuthenticatedProxyに任意のContractを実行させられるということです。

今回はERC721でしたが、もしこのデザインがtokenTransferProxyに適応されていた場合、ERC20のトークンをガッツリ抜かれます。
(実際にはtokenTransferProxyはdelegateCallを挟んでいないため、この攻撃は不可能)

OpenSea側からしてみれば、署名したユーザーの落ち度ということになると思いますが、正直言ってかなり功名な手口なので、熟練者じゃない限り見抜くのは難しいです。

ユーザーはどうすればいい?

  • 怪しいメール、DiscordのDMにリンクが貼ってあった場合、そこには飛ばない。宛先が偽装されている、乗っ取られていることもあると念頭に置くこと

  • MessageにSignするときは最新の注意を払う。「ガス代ないからいいや」って思ってない?



この記事が気に入ったらサポートをしてみませんか?