Neutralizing a critical vulnerability in Wyvern Protocol
—
TL;DR — In Q1 of 2022, a security researcher reported a critical vulnerability in the Wyvern 2.2 smart contracts that powered OpenSea. The vulnerability was neutralized before it could be exploited, and users are no longer at risk. We awarded the vulnerability reporter a bug bounty. Historical blockchain logs provide no indication that the vulnerability was ever exploited in the wild.
—
The “Horton Principle” is a maxim in designing cryptographic systems that says “mean what you sign and sign what you mean.” Earlier this year, a security researcher pseudonymously named Gus reached out to OpenSea to disclose a violation of this rule uncovered in Wyvern 2.2 — the core smart contract that powered OpenSea’s marketplace.
The researcher, in cooperation with the OpenSea security team, Wyvern protocol developers, and @samczsun, discovered that the vulnerability could be leveraged to steal offered WETH from the wallets of a subset of users with active offers on the OpenSea marketplace. The vulnerability required no action on the part of the user to exploit — certain users who had signed legitimate listings or offers in the past were at risk even if they took no further action.
This is the story of how the community – including Wyvern core developers, security researchers, and OpenSea – neutralized the vulnerability before it could impact users.
In the architecture of the Wyvern protocol, users author listings or offers off-chain by signing over commitments to the specifics of the listing or offer — most typically, parameters indicating an intent such as "I want to sell Bored Ape #312 for 100 WETH." These off-chain listings or offers can then be accepted by a counterparty using the Wyvern contracts on the Ethereum blockchain. For example, someone might elect to buy your listed NFT, in which case they would submit the signature you provided along with the listing or offer data in a call to the Wyvern contracts, and provide the payment required (which would go to you, less any fees).
Wyvern listings contain many different parameters used to indicate listing or offer information and authenticate other involved smart contract calls, which are aggregated together into a single commitment that the user signs and the contract checks –thus ensuring that items are only transferred if the user actually approves the listing or offer. Several of these parameters are variable-length, and the Wyvern 2.2 contracts (written before later standards in ABI encoding) concatenated them together without proper domain separation:
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);
Note particularly the three lines appending variable length byte arrays to the temporary array (which is hashed afterwards). In this implementation, orders with different parameters - say calldata = 0x01
and replacementPattern = 0x0101
and calldata = 0x0101
and replacementPattern = 0x01
- would have resulted in the same computed commitment. Due to this sort of collision, a clever adversary could have taken a signature from one listing or offer and come up with a different listing or offer - not signed by the user but resulting in the same commitment - that would have been considered by the smart contract to be valid. More specifically, bytes could be "shifted" by an attacker between order.calldata
, order.replacementPattern
, and order.staticExtradata
, to create an offer or listing with different semantics that would result in the same commitment.
In order to understand the particular way by which this can be exploited, it's important to understand how Wyvern works under the hood.
In the Wyvern system, digital items (such as NFTs) can be effectively "swapped" for other digital items via arbitrarily complex smart contract transactions. To allow this, listings or offers specify calldata
with which the Wyvern contracts will call a given target
contract in exchange for the listing or offer amount. In many cases, it is desirable for the calldata
to be mutated at time of fulfillment (in a highly constrained way).
This is accomplished generically through the concept of a replacementPattern
— a bitmask that the listing or offer maker commits to that specifies the portions of the calldata
the listing or offer maker is willing to have mutated by the taker. When the listing or offer is filled, the taker passes in calldata
that is mutated to their liking, and the Wyvern contracts apply the bitmask from the replacementPattern
in order to determine the bits in the final calldata
that the taker is allowed to submit mutated.
Why exactly is this clever machinery needed? Consider the following example:
In OpenSea offers, the offer specifies that Wyvern must successfully call the transferFrom
function on an ERC721 on the Ethereum blockchain in order for payment to be transferred to the offer taker. However, since the address of the taker is not known at the time of offer creation (offers are made specific to NFTs, not their holders), the offer must allow the taker to specify their address as the from
in the transferFrom
call at time of order fulfillment. Thus, the above mechanism is used to let the offer taker specifically mutate the portions of the transferFrom
calldata that specify the transfer's from
value.
This capability, in tandem with the Horton principle violation, is the crux of how this exploit could have materialized.
The first 4 bytes of calldata
specify which function should be called on a target contract. These bytes are called the function selector and are the first 4 bytes of the SHA3 hash of the function signature. In the above example, the function selector is 0x23b872dd
and corresponds to the signature transferFrom(address,address,uint256)
. This function inputs 3 parameters: address from, address to, uint tokenId
.
Violating Horton's principle allows for several potential exploits. The most potent of these was that, in certain clever cases, the attacker could "shift bytes" between the callData
and replacementPattern
in a manner that modified the resultant function selector that was called.
The function whose selector is "closest" (fewest bits differ) to transferFrom
is the function getApproved(uint)
with the selector 0x081812fc
. Only 10 bits differ between it and the function selector for transferFrom
.
There is a 1 in 1024 chance that a random, 4 byte bitmask has ones in the correct places such that, when applied through the replacementPattern
mechanism, the function selector would change from 0x23b872dd
to 0x081812fc
. Conveniently, an ETH address is effectively 20 random bytes.
Recall that, because of the Horton principle violation, an attacker can effectively shift adjacent bytes between the calldata
, replacementPattern
, staticTarget
and staticExtradata
values while arriving at the same order hash. Notably, an attacker could convince the Wyvern contracts that the order maker signed the former of these two payloads, when they in fact signed the latter:
// Malicious payload
...
'calldata': '0x23b872dd00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6005af36',
'replacementPattern': '0x6bfa60bbdba5966b9209e81567dedb00000000000000000000000000000000000000000000000000000000000002b300000000ffff',
'staticTarget': '0xffffffffffffffffffffffffffffffffffffffff',
'staticExtradata': '0xffffffffffffffffffff0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
...
// True payload
...
'calldata': '0x23b872dd00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6005af366bfa60bbdba5966b9209e81567dedb00000000000000000000000000000000000000000000000000000000000002b3',
'replacementPattern': '0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000',
'staticTarget': '0x0000000000000000000000000000000000000000',
'staticExtradata': '0x',
...
We can see that, in this example, our hypothetical attacker has shifted the last 15 bytes of the maker's address (which, in the true payload, is part of their specified "fixed" calldata) into the start of the replacementPattern
.
The 4 bytes 0x6bfa60bb
in the maker's address, coincidentally, have 1 bits in the places corresponding to the bitwise difference between transferFrom
and getApproved
. In turn, the attacker would be able to fulfill the order with calldata specifying a call to getApproved(0)
instead of transferFrom
:
...
'calldata' '0x081812fc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f6005af36'
...
The function call getApproved(0)
does not revert for 0-indexed ERC721 contracts and is also a view function, which does not update the state of the contract. If this exploit had been carried out, this would have allowed an attacker to fulfill an offer without sending or even owning the NFT the buyer wanted, while still being paid the victim's WETH.
Given that the attacker could shift the replacementPattern
to start at any point in the address, the attacker could extract 16 different 4 byte starts to the replacementPattern. This means approximately 1 in 64 offers could have been exploitable.
The OpenSea team was immediately compelled to action by the report of the vulnerability — the only way to protect our users was to migrate OpenSea’s listings and offers to a new version of Wyvern and disable Wyvern 2.2. The Wyvern governance contracts, however, mandate an upgrade timelock period of at least 14 days — meaning that, if we were not careful, we would incidentally alert potential attackers to the presence of the vulnerability before it had been neutralized.
Coincidentally, at this same time, OpenSea’s users were facing a persistent but unrelated issue: “hidden” listings. We have written extensively about the hidden listings issue, as well as the steps OpenSea has taken to help prevent similar attacks in the future.
It was clear that our best option was to “kill two birds with one stone” by initiating an upgrade that would solve both issues. We did this by including a new feature in the Wyvern 2.3 upgrade — EIP712 support, which implements a signature protocol that is not vulnerable to the Horton principle violation described above.
Our security & protocol teams raced to implement the Wyvern 2.3 contracts, and were fortunate to have Trail of Bits conduct an audit of the new contracts before their submission to the Wyvern DAO.
On February 18th, in partnership with the Wyvern governance community, we commenced the upgrade to Wyvern 2.3 and announced the migration publicly. Wyvern DAO stakeholders approved the upgrade proposal promptly and the two-week new version upgrade timer began its countdown.
Wyvern 2.3 was successfully migrated to on Feb. 25th and Wyvern 2.2 (the vulnerable smart contract) was disabled through Wyvern governance at the same time.
Our reviews of on-chain logs indicate that the vulnerability was fortunately never exploited in the wild.
For his responsible participation in the OpenSea bug bounty program, security researcher Gus was awarded a $3m bounty. We are thrilled to have seen the bug bounty program accomplish its stated goal in securing our users, and encourage other security professionals to submit any vulnerability reports through our Hacker One page.
Wyvern 2.3 is, at time of writing, the most used smart contract on Ethereum. Neutralizing a vulnerability in it was an incredibly delicate and high-stakes operation, and we at OpenSea are enormously grateful to everyone who lent time to this effort.
First and foremost — thank you to Gus for his responsible and productive participation in the OpenSea bug bounty program.
Second — we thank members of the broader community: the Wyvern core developers & community, @samczsun, and Trail of Bits.
Lastly — we want to acknowledge the tireless work of individual contributors on our team who lent countless nights and weekends to this effort.
There was a lot of talk on Twitter about OpenSea users getting phished in late February — was that related to this vunerability?
No — at the tail-end of the Wyvern 2.3 migration, approximately 10 OpenSea users were subjected to phishing attacks that occurred off of the OpenSea website. The phishing transactions did not leverage the above vulnerability in any capacity. Thankfully, the EIP712 standard that was launched in Wyvern 2.3 makes it much more difficult for attackers to dupe users into signing malicious payloads.
Was Wyvern 2.2 ever audited by third parties?
Yes — an audit was conducted by Solidified. Audits — though critical as a backstop for security — are not a silver bullet, and sometimes miss critical vulnerabilities, as illustrated by this case. This underscores the importance of bug bounty programs in the smart contract security space.
Where can I learn more about OpenSea's bug bounty program?
Information on OpenSea's bug bounty program can be found on HackerOne. Notably, we've recently updated our HackerOne to reflect the higher tier with which we pay out disclosures for critical vulnerabilities on the smart contracts we leverage.