OpenSea Seaport contest - 0xsanson's results

A marketplace contract for safely and efficiently creating and fulfilling orders for ERC721 and ERC1155 items.

General Information

Platform: Code4rena

Start Date: 20/05/2022

Pot Size: $1,000,000 USDC

Total HM: 4

Participants: 59

Period: 14 days

Judge: leastwood

Id: 128

League: ETH

OpenSea

Findings Distribution

Researcher Performance

Rank: 4/59

Findings: 3

Award: $88,255.26

🌟 Selected for report: 1

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: Spearbit

Also found by: 0xsanson, OriDabush, Saw-mon_and_Natalie, Yarpo, broccoli, cmichel, hyh, ming

Labels

bug
duplicate
3 (High Risk)
sponsor confirmed

Awards

22572.5904 USDC - $22,572.59

External Links

Lines of code

https://github.com/code-423n4/2022-05-opensea-seaport/blob/main/contracts/lib/OrderValidator.sol#L228-L231

Vulnerability details

When converting a number from uint256 to a smaller type, solidity truncates it without raising errors. In our case, this operation is performed when saving numerator and denominator to the storage variable _orderStatus[orderHash] (code link).

                _orderStatus[orderHash].numerator = uint120(
                    filledNumerator + numerator
                );
                _orderStatus[orderHash].denominator = uint120(denominator);

The impact of this bug is that it's possible to fill an order over the maximum quantity the offerer set up.

Proof of Concept

Let's consider an order which is OPEN and PARTIAL. Without loss of generality, we can consider that all amounts are divisible by 10 (kinda common number).

Bob is a fulfiller who makes an advanced fulfill with:

// 1st fulfill
numerator = 2**120 / 4
denominator = 2**120 / 2

This correctly brings _orderStatus to the same numerator and denominator. Bob bought half the order.

Now Bob make another fulfillment with:

// 2nd fulfill
numerator = 2
denominator = 5

This brings _orderStatus to:

_orderStatus.numerator = uint120(5 * 2**120 / 4 + 2 * 2**120 / 2) = 2**120 / 4
_orderStatus.denominator = uint120(5 * 2**120 / 2) = 2**120 / 2

Surprisingly it doesn't change, even though Bob brings home another 2/5 of an order. Bob can then repeat filling 2/5 until he likes.

Example: Alice is a creator who made a ERC1155 token. She wants to make a super sale where she sells only 10 of them for only 1 ETH! Unexpectedly, Bob is a massive fan and he's able to scoop 100 of them (for a total 10 ETH of course) (assuming Alice's balance and allowance are greater than that).

I've proved this exploit with a hardhat test, link to gist.

Add the following check before making the type conversion (around L222):

require(denominator <= type(uint120).max);

(we already know numerator < denominator).

#0 - 0age

2022-06-03T17:02:49Z

another partial fill finding

#1 - HardlyDifficult

2022-06-19T16:36:56Z

Dupe of #77

Findings Information

🌟 Selected for report: 0xsanson

Also found by: cmichel

Labels

bug
2 (Med Risk)
disagree with severity
sponsor confirmed

Awards

63711.4668 USDC - $63,711.47

External Links

Lines of code

https://github.com/code-423n4/2022-05-opensea-seaport/blob/main/contracts/lib/BasicOrderFulfiller.sol#L346-L349

Vulnerability details

When fulfilling a basic order we need to assert that the parameter totalOriginalAdditionalRecipients is less or equal than the length of additionalRecipients written in calldata. However in _prepareBasicFulfillmentFromCalldata this assertion is incorrect (L346):

        // Ensure supplied consideration array length is not less than original.
        _assertConsiderationLengthIsNotLessThanOriginalConsiderationLength(
            parameters.additionalRecipients.length + 1,
            parameters.totalOriginalAdditionalRecipients
        );

The way the function it's written (L75), it accepts also a length smaller than the original by 1 (basically there shouldn't be a + 1 in the first argument).

Interestingly enough, in the case additionalRecipients.length < totalOriginalAdditionalRecipients, the inline-assembly for-loop at (L506) will read consideration items out-of-bounds. This can be a vector of exploits, as illustrated below.

Proof of Concept

Alice makes the following offer: a basic order, with two considerationItems. The second item has the following data:

consideration[1] = {
	itemType: ...,
	token: ...,
	identifierOrCriteria: ...,
	startAmount: X,
	endAmount: X,
	recipient: Y,
}

The only quantities we need to track are the amounts X and recipient Y.

When fulfilling the order normally, the fulfiller will spend X tokens sending them to Y. It's possible however to exploit the previous bug in a way that the fulfiller won't need to make this transfer.

To do this, the fulfiller needs to craft the following calldata:

calldata pointercorrect calldataexploit calldata
.........
0x2041 (tot original)1 (tot original)
0x2240x240 (head addRec)0x240 (head addRec)
0x2440x2a0 (head sign)0x260 (head sign)
0x2641 (length addRec)0 (length addRec)
0x284X (amount)X (length sign)
0x2a4Y (recipient)Y (sign body)
0x2c40x40 (length sign)0x00 (sign body)
0x2e4[correct Alice sign]...
0x304[correct Alice sign]...

Basically writing additionalRecipients = [] and making the signature length = X, with Y being the first 32 bytes. Of course this signature will be invalid; however it doesn't matter since the exploiter can call validate with the correct signature beforehand.

The transaction trace will look like this:

  • the assertion _assertConsiderationLengthIsNotLessThanOriginalConsiderationLength passes;
  • the orderHash calculated is the correct one, since the for-loop over original consideration items picks up calldata at pointers {0x284, 0x2a4} (L513);
  • the order was already validated beforehand, so the signature isn't read;
  • at the end, during the tokens transfers, only offer and consideration[0] are transferred, since the code looks at additionalRecipients which is empty.

Conclusion:

Every Order that is "basic" and has two or more consideration items can be fulfilled in a way to not trade the last consideration item in the list. The fulfiller spends less then normally, and a recipient doesn't get his due.

There's also an extra requirement which is stricter: this last item's startAmount (= endAmount) needs to be smallish (< 1e6). This is because this number becomes the signature bytes length, and we need to fill the calldata with extra zeroes to complete it. Realistically then the exploit will work only if the item is a ERC20 will low decimals.

I've made a hardhat test that exemplifies the exploit. (Link to gist).

Remove the +1 at L347.

#0 - 0age

2022-06-03T17:01:24Z

Valid finding on the off-by-one error, this was already reported to us outside of c4 and we're going to fix β€”Β will mention that it's very difficult to find / craft exploitable payloads though, so severity should be lower

#1 - 0xleastwood

2022-06-23T17:05:16Z

While the issue outlines an exploit whereby an attacker can fulfill an order without paying the entire consideration amount, it does require a set of requirements, namely:

  • The item is an ERC20 with low decimals.
  • The order has considerationItems > 1.

Maximum extractable value for the most prevalent ERC20 token with low decimals, WBTC. This token uses 8 decimals and currently we know that calldata uses 16 gas for each byte used. Based on a block gas limit of 30,000,000, we can deduce that the calldata length has an upper bound of 1.875 MB. Based on this, the maximum extractable value would be (1,875,000 / 1e8) * $20,000 USD = $375 USD, assuming the price for each WBTC is $20,000 USD.

Relevant EIP detailing this is found at https://eips.ethereum.org/EIPS/eip-4488.

It is also important to note, that by utilising the entire available block space on Ethereum, it is very likely that the cost of the transaction will far exceed the amount received in the attack.

The attack does in fact leak value by allowing orders to be fulfilled at a slight discount. However, because this only affects very specific order types, I believe medium severity to be justified.

2 β€” Med: Assets not at direct risk, but the function of the protocol or its availability could be impacted, or leak value with a hypothetical attack path with stated assumptions, but external requirements.

This was one of the most interesting issues I've read, kudos to those who found it!

#2 - liveactionllama

2022-08-30T15:18:16Z

Awards

1971.1956 USDC - $1,971.20

Labels

bug
QA (Quality Assurance)
sponsor disputed

External Links

Lines of code

https://github.com/code-423n4/2022-05-opensea-seaport/blob/main/contracts/lib/TokenTransferrer.sol#L239

Vulnerability details

Impact

The code currently moves ERC721 around using the transferFrom(from,to,id) function. According to EIP-721, when using this function "the caller is responsible to confirm that the recipient (to) is capable of receiving NFTs or else they may be permanently lost". Currently this check isn't performed.

The issue is of course that NFTs can be lost.

Proof of Concept

https://github.com/code-423n4/2022-05-opensea-seaport/blob/main/contracts/lib/TokenTransferrer.sol#L239 https://github.com/code-423n4/2022-05-opensea-seaport/blob/main/contracts/lib/TokenTransferrerConstants.sol#L85

The standard is to use the safeTransferFrom function, which already makes the necessary check. To use the safe version, just change the constant ERC721_transferFrom_signature to ERC721_safeTransferFrom_signature = abi.encodeWithSignature("safeTransferFrom(address,address,uint256)").

#0 - 0age

2022-06-03T17:21:34Z

conscious decision not to use safeTransferFrom for gas + reentrancy reasons. Should check off-chain and/or use a router that performs the checks if desired.

#1 - HardlyDifficult

2022-06-20T16:03:46Z

Lowing this to a low severity. Making this issue a QA report for the warden.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter