見出し画像

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するときは最新の注意を払う。「ガス代ないからいいや」って思ってない?



いいなと思ったら応援しよう!

コメント

ログイン または 会員登録 するとコメントできます。
OpenSeaのフィッシングを理解しよう|dK
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1