OpenSea / Wyvern Protocol Notes (Draft - Unfinished)
librehash
Posted on October 5, 2022
Somewhat complex how this works.
OpenSea = lazy minting (doesn't mint until you actually execute the sale; seems like this is what got those users when looking at the stack trace of those transactions)
"It never tells you why and for what purpose you are signing transactions!" (curious; https://forum.openzeppelin.com/t/eli5-how-opensea-nft-works/7645)
"OpenSea uses the WyvernProtocol, an (audited, battle tested) system that creates a personal proxy contract for each user. We dont' control the proxies that get created, and you have to approve access to each ERC721 contract individually (and some contracts, like CryptoKitties, you have to approve each asset individually) before the proxy can access any of your assets."
"Once a proxy contract is approved, you can sign orders indicating you are willing to sell a given asset for a specific price. The logic of the exchange contract only allows it to transfer an asset from your proxy only if A) you have signed an order, and B) it is properly matched by a buyer paying the appropriate funds. So not only does your asset never leave your wallet, it's only allowed to be swapped if an order you create is properly matched." #foodforthoughts
"The proxy addresses are created programmatically and can't be changed"
Quotes above are from a Reddit where someone answered about the way Wyvern Protocol works - https://www.reddit.com/r/opensea/comments/a3tax6/opensea_security/
More Information about Creation of Proxy Contract by WyvernProtocol
Some decent information here - https://victoryeo-62924.medium.com/wyvern-protocol-in-opensea-nft-marketplace-b0cef9a9143a
Mainly:
"Every user has a proxy smart contract" (apparently this is created for them by the WyvernProtocol itself; so each proxy smart contract address should be unique to a given user on the OpenSea platform - cool, got it)
"Each item which is traded on OpenSea is owned by a Proxy smart contract of a user" (okay, more food for thoughts)
Curious Facts Regarding the Mandatory Use of Proxy Contracts for Authorizing Trade
As stated above, the Wyvern Protocol is responsible for generating unique proxy addresses for each user on the platform. These proxy addresses are what are ultimately responsible for granting / revoking permissions.
Specifically, one particular function seemed to be a source of trouble for many users on the OpenSea platform and that was the isApprovedForAll
function (which is auto-overrided in the current Wyvern v3 spec). Specifically, that function was found within the ERC1155 and ERC721 smart contracts for OpenSea's platform.
The function was written as such:
function isApprovedForAll(address owner, address operator)
public
view
override
returns (bool)
{
// allows gasless trading on OpenSea
return super.isApprovedForAll(owner, operator) || isOwnersOpenSeaProxy(owner, operator);
}
Extracted this code from here = https://gist.github.com/dievardump/483eb43bc6ed30b14f01e01842e3339b/revisions (underneath the 'revisions' because the user that published these gists later redacted them out of concern for end-user compromise in lieu of the permissions granted by this type of smart contract)
More information about how this function works can be found directly on OpenSea's site:
https://docs.opensea.io/docs/1-structuring-your-smart-contract#opensea-whitelisting-optional
Worth noting here is the statement, "Additionally, the ERC721Tradable
and ERC1155Tradable
contracts whitelist the proxy accounts of OpenSea users so that they are automatically able ot trade any item on OpenSea (without having to pay gas for additional approval)."
Essentially what this means is that when a user purchases an NFT on OpenSea (i.e., they receive a ERC721Tradable
/ ERC1155Tradable
) that user would typically need to grant explicit permission to their proxy contract before being allowed to trade any given NFT (within a collection). Making that request explicitly would require a separate transaction in itself (in addition to the one necessary to sell the NFT).
Thus, OpenSea allowed users to 'whitelist' their proxy address via the isApprovedForAll
function.
From here, we're going to double back over to the WyvernProtocol code specification for their smart contracts, which you can find here: https://docs.projectwyvern.com/docs/ProxyRegistry/
Specifically, we're going to take a look at the registerProxy
function (represented by function: ddd81f82
), which allows one to, "register a proxy contract with this registry" (i.e., registry being OpenSea / Wyvern).
Registering a proxy contract with the registry requires the express permission of the user (via signature via Metamask). As we read above, each user on OpenSea has a proxy address provisioned for them. And in order for a user to save on gas / allow gasless listings & sales on OpenSea, they only need to forego overriding the isApprovedForAll
function within the ERC standard contract (either 1155 or 721; depending on the type of NFT).
Remember that excerpt from OpenSea's website:
This is likely the point at which users were actually phished, because this access here is what facilitated the actual theft of NFTs from users at a latter point in time. That claim about this access being the inflection point for wallet compromise is proven via examination of transactions related to affected users. Specifically, the stack trace gives us more than enough information to begin pinpointing the culprit here.
Brief Note: The smart contract(s) that played a key role in the compromise of affected OpenSea users are not marked on Etherscan; the only exception is the smart contract that's used in the orchestration that's obviously attached (i.e., it actually emits various event during the execution of the transaction itself)
The specification for the WyvenProxyRegistry can be found here: https://abidocs.dev/contracts/0xa5409ec958c83c3f309868babaca7c86dcb077c1 (check out the contract name in the URL)
Below is the necesssary code template for those looking make this call on a smart contract (for whatever reasons):
function registerProxy()
public
returns (OwnableDelegateProxy proxy)
{
require(proxies[msg.sender] == address(0));
proxy = new OwnableDelegateProxy(msg.sender, delegateProxyImplementation, abi.encodeWithSignature("proxies"[msg.sender] = proxy;
return proxy;
}
A reference for functions attached to OpenSea's smart contract orchestration can be found here as well: https://abidocs.dev/dapps/opensea
Specifically, we're going to take a look at how one may want to make use of that function (vs. any of the myriad of other choices they have).
As one can see:
registerProxy
doesn't haveonlyOwner
attached it. Functions that do have this modifier attached to them can only be called by the owner of the proxy contract. Yet as we can see one does not have to be the owner to callregisterProxy
This is not an event, so nothing is emitted when this function is called
Within the context of normal OpenSea marketplace behavior, this function is only used when a user is lookiong to purchase an NFT. According to OpenSea "if you're buying an item on Ethereum, the transaction will show as 'registerProxy' /
Register Proxy
".
Proxy contracts exist underneath the umbrella of upgradable contracts. That's why you'll find the variable OwnableDelegateProxy
littered throughout the example code we outlined above.
Let's take a look at some example code OpenZeppelin gives us for a vanilla contract proxy (will have some minor variances, but the gist is the same):
// This code has not been professionally audited, therefore I cannot make any promises about
// safety or correctness. Use at own risk.
contract Proxy {
address delegate;
address owner = msg.sender;
function upgradeDelegate(address newDelegateAddress) public {
require(msg.sender == owner);
delegate = newDelegateAddress;
}
function() external payable {
assembly {
let _target := sload(0)
calldatacopy(0x0, 0x0, calldatasize)
let result := delegatecall(gas, _target, 0x0, calldatasize, 0x0, 0)
returndatacopy(0x0, 0x0, returndatasize)
switch result case 0 {revert(0, 0)} default {return (0, returndatasize)}
}
}
}
ignore the function that deals with
external payable
, we're not going to discuss any assembly code yet at this point
Notice there's access restriction in that code excerpt. This access restriction is created by defining address owner = msg.sender
then require(msg.sender == owner)
. On the contrary, for the OpenSea registerProxy
the market is forced to forego this logic in its implementation, instead opting for, require(proxies[msg.sender] == address(0))
.
The reason why OpenSea must forego this access restriction is because the buyer is the one making this call (thus, the buyer is the 'sender' in this instance; confusing but stick with me here). The lack of access restriction is to prevent NFT listers from scamming those who buy their NFTs by yanking them back after receiving payment.
Remember the function definitions we found for OpenSea's marketplace smart contracts. Specifically, we saw that onlyOwner
was not a modifier attached to the registerProxy
function (since the buyer needs to be able to propose whichever smart contract they desire).
Now let's go back to one of the transactions involving an affected user from February 19th/20th (when folks were getting their NFTs taken left & right, it seemed).
Let's use this TX for reference: 0xfd3ac0804b9f8af8e0185ecc43c855867a04ed10eede2d80295174e5a7024038
Specifically, we're going to pipe that TX into 'ethtx.info' (first time ever including tihs site in a report, but I really like it; great for getting the granular details of an Ethereum transaction for reports like these). Once we pipe that address in to the search, we should wind up here: https://ethtx.info/mainnet/0xfd3ac0804b9f8af8e0185ecc43c855867a04ed10eede2d80295174e5a7024038/
More than likely your screen will show you the following at the top of the page:
Let's scroll down until reach something called the 'execution trace'.
This is what gives us information about the various updated states of the transaction as it was processed by nodes / miners on the Ethereum network (this work here is what the gas payments are for; i.e., the more work that needs to do + more congested the network gets, the more one must pay in gas to have their transaction cleared - thus, the commodity for $ETH is gwei at the end of the day).
This may seem intimidating, but we're going to tackle this one slowly (easier than you think).
Check out what we have below:
For the second state update, the 'execution trace' shows us [receiver] 0xa2c0946ad444dccf990394c5cbe019a858a945bd.0x8a10f9ce(call_data=0x000000000000000000000000c99f70bfd82fb7c8f8191fdfbfb735606b...000000) => ()
Before we pick apart what this means, let's go take a look at that smart contract listed as the [receiver]
there as this was the one smart contract everyone was talking about on Twitter while the actual heist was going on.
For this, we'll refer to Etherscan: https://etherscan.io/address/0xa2c0946ad444dccf990394c5cbe019a858a945bd
As we can see above, Etherscan is already all over this address - labeling it Fake_Phishing5176
. However, a more accurate name for this smart contract would be Phisher_GetAwayDriver
or something to that extent
mini-rant: seriously though, there should be a more standardized method for labeling addresses & smart contracts identified as being a facilitator of some widely known hack / theft / compromise in the community
Let's see if we can view the contract code on Etherscan. First, we'll scroll down and click the 'contract' tab.
And it appears (maybe) that there's no such luck for us:
At first glance, the obvious assumption is that whomever uploaded this contract did so in this manner (no source code available) with the explicit intent of foiling "armchair investigators" like myself and (the few) others that bother to take any time examining these things.
However, this may not have been the case. If we revisit that brilliant guide breaking down 'proxy delegates', we'll see that inline assembly was likely a requisite for the attackers to pull off a successful theft of NFT assets from impacted users.
Specifically we want to note the part where it states, "When using the call in the context of a proxy, however, we are interested in the actual return value of the function call. To overcome this limitation, inline assembly (inline assembly allows for more precise control over the stack machine, with a language similar to the one used by the EVM and can be used within solidity code) has to be used. With the help of inline assembly we are able to dissect the return value of delegatecall
and return the actual result of the caller."
Remember, in order for an NFT sale to execute on OpenSea's platform, there must be a matched buy & sell order. This is validated via examining whether the sender (i.e., 'buyer') of the NFT has properly signed the "call data" created by the seller's proposed sale price.
This is the Wyvern smart contract that governs that exchange process: https://github.com/ProjectWyvern/wyvern-ethereum/blob/master/contracts/exchange/ExchangeCore.sol
Critically, the code notes for this contract state, "Buy-side and sell-side orders each provide calldata
(bytes) - for a sell-side order, the state transition for sale, for a buy-side order, the state transition to be bought." Also, "Along with the calldata, orders provide replacementPattern
: a bytemask indicating which bytes of the calldata can be changed (e.g. NFT destination address)."
That's curious. To digress for a moment, is it possible that users were 'phished' legitimately purchasing NFTs on OpenSea's platform? Before you answer, hold that thought. There's more.
"When a buy-side and sell-side order are matched, the desired calldatas are unified, masked with the bytemasks, and checked for agreement. This alone is enough to implement common simple state transitions, such as 'transfer my CryptoKitty to any address' or 'buy any of this kind of nonfungible token'."
This is what the atomicMatch_
function we see called on the Wyvern smart contract is emitted for.
The answer as to how users may have been compromised may lie in a potential instance of proxy collisions as well as the non-upgraded Wyvern contract's acceptance of isValidSignature
before upgrade vs. after.
To get a better idea of what's being referred to here, check out the following update to the Wyvern Protocol smart contract orchestration (pushed Jan 31st, 2022; just a couple of weeks before the changes were merged upstream with OpenSea).
Check them out here: https://github.com/wyvernprotocol/wyvern-v3/commit/403f866940b4ef304d24c2147bd9503e89e1cec7
Specifically under ExchangeCore.sol
, we can see that the bytes4 constant internal EIP_1271_MAGICVALUE
was changed from 0x20c13b0b
to 0x1626ba7e
.
Specifically, the code used to read:
contract ExchangeCore is ReentrancyGuarded, StaticCaller, EIP712 {
bytes4 constant internal EIP_1271_MAGICVALUE = 0x20c13b0b;
Now it reads:
contract ExchangeCore is ReentrancyGuarded, StaticCaller, EIP712 {
bytes4 constant internal EIP_1271_MAGICVALUE = 0x1626ba7e;
For those that do not know, smart contracts do not come with associated private keys. The only type of addresses on Ethereum that come with private keys are EOAs (externally owned accounts). EOAs = regular Joe Blow Ethereum addresses (i.e., no smart contract logic or anything fancy; regular ETH address you generate when you create a new account w Metamask or some other wallet provider).
So instead there must be another means of validating a signature if its passed from a smart contract. For OpenSea / Wyvern Protocol, that method was governed by ERC1271.
If you're not familiar with that smart contract standard, look no further than here: https://eips.ethereum.org/EIPS/eip-1271
For those curious enough to follow the link above, you may have noticed that the suggested magic number for EIP-1271 under the specification = 0x1626ba7e
(dating back to solidity 0.5.0; this was written in 2018).
Specifically the sample code given under this EIP is:
pragma solidity ^0.5.0;
contract ERC1271 {
// bytes4(keccak256("isValidSignature(bytes32,bytes)")
bytes4 constant internal MAGICVALUE = 0x1626ba7e;
/**
* @dev Should return whether the signature provided is valid for the provided hash
* @param _hash Hash of the data to be signed
* @param _signature Signature byte array associated with _hash
*
* MUST return the bytes4 magic value 0x1626ba7e when function passes.
* MUST NOT modify state (using STATICCALL for solc < 0.5, view modifier for solc > 0.5)
* MUST allow external calls
*/
function isValidSignature(
bytes32 _hash,
bytes memory _signature)
public
view
returns (bytes4 magicValue);
}
The reason for the variation in the bytes4 magicValue
in the Wyvern Protocol (older contract) and the one specified in EIP1271 are the structs that are included under each.
Judging from the commits to the Wyvern Protcol smart contracts on GitHub, it appears that the biggest change was made with regards to smart contract authentication for validating access restrictions related to calls made on the main smart contract.
We can see this under the contracts/exchange/ExchangeCore.sol
contract underneath the main repo for WyvernProtocol: https://github.com/wyvernprotocol/wyvern-v3/commit/403f866940b4ef304d24c2147bd9503e89e1cec7
The contract used to read:
contract ExchangeCore is ReentrancyGuard, StaticCaller, EIP712 {
bytes4 constant internal EIP_1271_MAGICVALUE = 0x20c13b0b;
bytes internal personalSignPrefix = "\x19Ethereum Signed Message:\n";
// skipping about 150 lines to line 185
if (isContract) {
if (ERC1271(maker).isValidSignature(abi.encodePacked(calculatedHashToSign), signature) == EIP_1271_MAGICVALUE) {
return true;
}
return false;
}
(uint8 v, bytes32 r, bytes32 s) = abi.decode(signature, (uint8, bytes32, bytes32));
if (signature.length > 65 && signature[signature.length-1] == 0x03) {
if (ecrecover(keccak256(abi.encodePacked(personalSignPrefix,"32",calculatedHashToSign)), v, r, s) == maker) {
return true;
}
}
}
// last bracket added for cohesiveness
}
The two lines that were deleted in the code were:
(a) if (ERC1271(maker).isValidSignature(abi.encodePacked(calculatedHashToSign), signature) == EIP_1271_MAGICVALUE) {
(b) bytes4 constant internal EIP_1271_MAGICVALUE = 0x20c13b0b;
They were replaced with
(aa) bytes4 constant internal EIP_1271_MAGICVALUE = 0x1626ba7e;
(bb) if (ERC1271(maker).isValidSignature(calculatedHashToSign, signature) == EIP_1271_MAGICVALUE) {
The swap from abi.encodePacked(calculatedHashtoSign)
to calculatedHashtoSign
was a critical change in the authentication mechanism for smart contracts attempting to make cals calls on the exchange.sol
contract deployed by Wyvern Protocol.
Keccak Caching
All contracts deployed before solidity version 0.8.3 were impacted by a bug called KeccakCaching
; according to Etherscan, "The bytecode optimizer incorrectly re-used previously evaluated Keccak-256 hashes. You are unlikely to be affected if you do not compute Keccak-256 hashes in inline assembly."
Coincidentally, that's exactly what the malicious smart contract (labeled phishing_contract5961
on Etherscan), did. The source code was not published on Etherscan, but there are plenty of tools in the wild that allow us to decompile the smart contract's ABI (decoding) as well as the involved transactions to get a general feel for how this compromise was executed.
This bug was not discovered / published about publicly until March 20th 2021 (approx.) on the 'SolidityLang' blog.
That post can be viewed here: https://blog.soliditylang.org/2021/03/23/keccak-optimizer-bug/
Below is the basic gist of how this bug works:
"Solidity’s bytecode optimizer has a step that can compute Keccak-256 hashes, if the contents of the memory, over which the Keccak-256 built-in function is invoked, are known during compilation time."
"This step also has a mechanism to determine that two Keccak-256 hashes are equal even if the values in memory are not known during compile time"
"This step had a bug where Keccak-256 hashes of the same memory content, but of different sizes were considered equal."
To synthesize those details, this bug essentially allowed for asserting that a keccak256 hash of some data (i.e., keccak256(0, 32)
or let's say.... keccak256(add(array, 0x20), size)
) [hint: that latter function is located at line 656 of Wyvern's Exchange smart contract (earlier version; deprecated now), and is also explicitly calculated via in-line assembly, making the contract ripe for those looking to compromise users via OpenSea's market at the time this was the deployed standard]
Making an Educated Guess (Hypothesis)
At this point, what I believe happened is that users that bought certain NFTs (i.e., Bored Ape Yacht Club
NFTs), signed calldata well in
We'll soon find out that the phishing attack was more than likely executed via the NFTs themselves, but not in the simplistic way originally visioned by 'Checkpoint Research' (all credit to those researchers for exposing various site vulnerabilities that would pull in malicious html code appearing to originate from the same site).
This attack vector identified by 'Checkpoint Research' is still potent, to be clear. But it is only just one among a gamut of compromise techniques being leveraged against the NFT ecosystem (appears that there is a potent threat actor that's been wreaking havoc on the protocol).
This is still unfinished, so there's no conclusion in here yet and this research doesn't get to the crux of how the compromise of user NFTs was executed back in February 2022; with that said, don't extrapolate anything from what's written above yet. This is just here as a placeholder
Posted on October 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024