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のaddrsとcalldataSellです。
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は偽のContractをdelegateCallします。
/* 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するときは最新の注意を払う。「ガス代ないからいいや」って思ってない?
この記事が気に入ったらサポートをしてみませんか?