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
Rank: 1/59
Findings: 5
Award: $292,640.04
🌟 Selected for report: 3
🚀 Solo Findings: 0
22572.5904 USDC - $22,572.59
https://github.com/code-423n4/2022-05-opensea-seaport/blob/4140473b1f85d0df602548ad260b1739ddd734a5/contracts/lib/OrderValidator.sol#L228 https://github.com/code-423n4/2022-05-opensea-seaport/blob/4140473b1f85d0df602548ad260b1739ddd734a5/contracts/lib/OrderValidator.sol#L231 https://github.com/code-423n4/2022-05-opensea-seaport/blob/4140473b1f85d0df602548ad260b1739ddd734a5/contracts/lib/OrderValidator.sol#L237 https://github.com/code-423n4/2022-05-opensea-seaport/blob/4140473b1f85d0df602548ad260b1739ddd734a5/contracts/lib/OrderValidator.sol#L238
A partial order's fractions (numerator
and denominator
) can be reset to 0
due to a truncation. This can be used to craft malicious orders:
marketplaceContract
.PARTIAL_OPEN
order with 10 ERC1155 tokens and consideration of ETH.1 / 2
, Bob provides 2**118 / 2**119
. This sets the totalFilled
to 2**118
and totalSize
to 2**119
.1 / 10
. The computation 2**118 / 2**119 + 1 / 10
is done by "cross multiplying" the denominators, leading to the acutal fraction being numerator = (2**118 * 10 + 2**119)
and denominator = 2**119 * 10
.uint120
truncation in OrderValidator.sol#L228-L248, the numerator
and denominator
are truncated to 0
and 0
respectively.For a full POC: https://gist.github.com/hrkrshnn/7c51b23f7c43c55ba0f8157c3b298409
The following change would make the above POC fail:
modified contracts/lib/OrderValidator.sol @@ -225,6 +225,8 @@ contract OrderValidator is Executor, ZoneInteraction { // Update order status and fill amount, packing struct values. _orderStatus[orderHash].isValidated = true; _orderStatus[orderHash].isCancelled = false; + require(filledNumerator + numerator <= type(uint120).max, "overflow"); + require(denominator <= type(uint120).max, "overflow"); _orderStatus[orderHash].numerator = uint120( filledNumerator + numerator ); @@ -234,6 +236,8 @@ contract OrderValidator is Executor, ZoneInteraction { // Update order status and fill amount, packing struct values. _orderStatus[orderHash].isValidated = true; _orderStatus[orderHash].isCancelled = false; + require(numerator <= type(uint120).max, "overflow"); + require(denominator <= type(uint120).max, "overflow"); _orderStatus[orderHash].numerator = uint120(numerator); _orderStatus[orderHash].denominator = uint120(denominator); }
Manual review
A basic fix for this would involve adding the above checks for overflow / truncation and reverting in that case. However, we think the mechanism is still flawed in some respects and require more changes to fully fix it. See a related issue: "A malicious filler can fill a partial order in such a way that the rest cannot be filled by anyone" that points out a related but a more fundamental issue with the mechanism.
#0 - 0xleastwood
2022-06-21T22:26:17Z
I've identified that this issue and all of its duplicates clearly outline how an attacker might overflow an order to continually fulfill an order at the same market price.
An instance where this issue might cause issues is during a restricted token sale. A relevant scenario is detailed as follows:
OrderValidator
, the order fulfillment can be reset to allow the public to more than 50% of the total token supply.For these reasons, I believe this issue to be of high severity because it breaks certain trust assumptions made by the protocol and its userbase. By intentionally forcing a user to sell additional tokens, you are effectively altering the allocation of their wallet holdings, potentially leading to further funds loss as they may incur slippage when they have to sell these tokens back.
A great finding from all involved!
#1 - liveactionllama
2022-08-30T15:12:47Z
Per @0age resolved via: https://github.com/ProjectOpenSea/seaport/pull/319
🌟 Selected for report: Spearbit
Also found by: Saw-mon_and_Natalie
212371.5561 USDC - $212,371.56
The _aggregateValidFulfillmentOfferItems()
function aims to revert on orders with zero value or where a total consideration amount overflows. Internally this is accomplished by having a temporary variable errorBuffer
, accumulating issues found, and only reverting once all the items are processed in case there was a problem found. This code is optimistic for valid inputs.
Note: there is a similar issue in _aggregateValidFulfillmentConsiderationItems()
, which is reported separately.
The problem lies in how this errorBuffer
is updated:
// Update error buffer (1 = zero amount, 2 = overflow). errorBuffer := or( errorBuffer, or( shl(1, lt(newAmount, amount)), iszero(mload(amountPtr)) ) )
The final error handling code:
// Determine if an error code is contained in the error buffer. switch errorBuffer case 1 { // Store the MissingItemAmount error signature. mstore(0, MissingItemAmount_error_signature) // Return, supplying MissingItemAmount signature. revert(0, MissingItemAmount_error_len) } case 2 { // If the sum overflowed, panic. throwOverflow() }
While the expected value is 0
(success), 1
or 2
(failure), it is possible to set it to 3
, which is unhandled and considered as a "success". This can be easily accomplished by having both an overflowing item and a zero item in the order list.
This validation error could lead to fulfilling an order with a consideration (potentially ~0) lower than expected.
Craft an offer containing two errors (e.g. with zero amount and overflow).
Call matchOrders()
. Via calls to _matchAdvancedOrders()
, _fulfillAdvancedOrders()
, _applyFulfillment()
, _aggregateValidFulfillmentOfferItems()
will be called.
The errorBuffer
will get a value of 3 (the or
of 1 and 2).
As the value of 3 is not detected, no error will be thrown and the order will be executed, including the mal formed values.
Manual review
case 3
.errorBuffer != 0
on FulfillmentApplier.sol#L338#0 - 0age
2022-06-02T17:59:20Z
duplicate of #69
#1 - MrToph
2022-07-02T06:50:51Z
This validation error could lead to fulfilling an order with a consideration (potentially ~0) lower than expected.
That's correct, you can use this to fulfill an order essentially for free, that's why I'd consider this high severity. They could have done a better job demonstrating it with a POC test case but this sentence imo shows that they were aware of the impact.
See this test case showing how to buy an NFT for 1 DAI instead of 1000 DAI.
#2 - 0xleastwood
2022-07-13T11:41:04Z
After further consideration and discussion with @HardlyDifficult, we agree with @MrToph that this should be of high severity. As the protocol allows for invalid orders to be created, users aware of this vulnerability will be able to fulfill an order at a considerable discount. This fits the criteria of a high severity issue as it directly leads to lost funds.
#3 - liveactionllama
2022-08-30T15:15:03Z
Per @0age resolved via: https://github.com/ProjectOpenSea/seaport/pull/320
38226.8801 USDC - $38,226.88
https://github.com/code-423n4/2022-05-opensea-seaport/blob/main/contracts/lib/CriteriaResolution.sol#L155 https://github.com/code-423n4/2022-05-opensea-seaport/blob/main/contracts/lib/CriteriaResolution.sol#L241
_verifyProof
allows empty proofs and in that case it expects the leaf
to equal the root
, because no hashing and iteration is taking place. The purpose of the tree is to hold multiple accepted tokenId
s, where the consideration contains one and proving its existence in the order via the proof. Because of this the leaf
equals to a single tokenId
.
This can manifest in distinct issues:
A criteria order with an empty proof is the same as a non-criteria order with the exception of the ItemType
is differing (e.g. ItemType.ERC721
vs ItemType.ERC721_WITH_CRITERIA
). This is a slight malleability symptom.
In the case of leaf=0
(tokenId=0
) the _verifyProof
function is not even executed.
Context: CriteriaResolution.sol#L241, CriteriaResolution.sol#L155
Manual review
Hash the leaf node or disallow empty proofs.
#0 - 0age
2022-06-03T23:40:10Z
This is valid, and we intend to fix — it is also a subset of the more generalized finding on criteria proofs where intermediate nodes of the proof can be used as leaves (and has the same mitigation, hashing leaf nodes)
#1 - HardlyDifficult
2022-06-20T21:16:01Z
🌟 Selected for report: Spearbit
Also found by: 0xsanson, Chom, IllIllI, OriDabush, Saw-mon_and_Natalie, broccoli, cccz, cmichel, csanuragjain, foobar, hack3r-0m, hickuphh3, hubble, hyh, ilan, kebabsec, mayo, oyc_109, peritoflores, rfa, scaraven, sces60107, shung, sorrynotsorry, tintin, twojoy, zkhorse, zzzitron
18443.0418 USDC - $18,443.04
We have an extensive QA report with 23 different findings in https://gist.github.com/hrkrshnn/d08e02519a172ba938195093adba0a09
Submitting it as a separate gist due to size limit, and also to have syntax highlighting and MathJax support.
#0 - HardlyDifficult
2022-06-20T18:57:14Z
#1 - HardlyDifficult
2022-06-20T20:46:53Z
#2 - HardlyDifficult
2022-06-20T21:07:29Z
#3 - HardlyDifficult
2022-06-20T21:22:16Z
#4 - 0xleastwood
2022-06-30T10:46:19Z
This report and its merged issues highlight several limitations which are informative to the Opensea team. This report is of high quality and is deserving of the best score. I consider all issues raised to be valid.
🌟 Selected for report: Dravee
Also found by: 0x1f8b, 0x29A, 0xalpharush, Chom, Czar102, Hawkeye, IllIllI, MaratCerby, MiloTruck, NoamYakov, OriDabush, RoiEvenHaim, Spearbit, Tadashi, TerrierLover, TomJ, asutorufos, cccz, cmichel, csanuragjain, defsec, delfin454000, djxploit, ellahi, foobar, gzeon, hake, hickuphh3, ignacio, ilan, joestakey, kaden, mayo, ming, oyc_109, peritoflores, rfa, sach1r0, sashik_eth, shung, sirhashalot, twojoy, zer0dot, zkhorse
1025.9709 USDC - $1,025.97
The function _applyFractionsAndTransferEach()
contains two function pointer assignments, which are difficult to read.
They could also be set in the constructor and stored immutable
, which will make the code more readable and save some gas.
Context: OrderFulfiller.sol#L199-L214, OrderFulfiller.sol#L289-L303
contract OrderFulfiller is ... function _applyFractionsAndTransferEach(...) ... { ... function(OfferItem memory, address, bytes32, bytes memory) internal _transferOfferItem; function(ReceivedItem memory, address, bytes32, bytes memory) internal _transferReceivedItem = _transfer; assembly { _transferOfferItem := _transferReceivedItem } ... function(ConsiderationItem memory, address, bytes32, bytes memory) internal _transferConsiderationItem; function(ReceivedItem memory, address, bytes32, bytes memory) internal _transferReceivedItem = _transfer; assembly { _transferConsiderationItem := _transferReceivedItem } } }
Manual review
Consider setting the function pointers in the constructor, as shown in the following example:
//SPDX-License-Identifier: MIT pragma solidity 0.8.14; import "hardhat/console.sol"; contract TestFunctionPointers{ struct OfferItem { uint256 startAmount; } struct ReceivedItem { uint256 identifier; } function _transfer(OfferItem memory oi) public { console.log("In _transfer",oi.startAmount); } function(ReceivedItem memory) internal immutable _transferReceivedItem; constructor() { function(OfferItem memory) internal fpa = _transfer; function(ReceivedItem memory) internal fpb; assembly { fpb := fpa } _transferReceivedItem = fpb; ReceivedItem memory ri; ri.identifier = 1234567; _transferReceivedItem(ri); } }
_verifyProof()
The function _verifyProof()
of contract CriteriaResolution
uses a switch
statement. This can be optimized to run withouth a jump
to save some gas. As this is within a loop, it might be worth the effort.
Context: CriteriaResolution.sol#L265-L273
This is the current code:
function _verifyProof( ... ) ... { ... assembly { ... for { ... } { ... } ... switch gt(computedHash, loadedData) // here is a jump case 0 { mstore(0, computedHash) // Place existing hash first. mstore(0x20, loadedData) // Place new hash next. } default { mstore(0, loadedData) // Place new hash first. mstore(0x20, computedHash) // Place existing hash next. } ... } }
Manual review
Consider using the following:
assembly { let g := mul( gt(computedHash, loadedData), 0x20) mstore(g, computedHash ) mstore(sub(0x20,g), loadedData ) }
Here is some test code to see the difference:
//SPDX-License-Identifier: MIT pragma solidity 0.8.14; import "hardhat/console.sol"; contract TestStore{ function min(uint a,uint b) internal pure returns(uint) { if (a<b) return a; else return b; } function t1(uint computedHash , uint loadedData) internal { assembly { switch gt(computedHash, loadedData) case 0 { mstore(0, computedHash) mstore(0x20, loadedData) } default { mstore(0, loadedData) mstore(0x20, computedHash) } } } function t2(uint computedHash , uint loadedData) internal { assembly { let g := mul( gt(computedHash, loadedData), 0x20) mstore(g, computedHash ) mstore(sub(0x20,g), loadedData ) } } constructor() { uint n=11;uint g1;uint g2;uint g=type(uint).max;uint i; g=type(uint).max;g1=gasleft();for(i=0;i<n;i++) { t1(i,4); } g2=gasleft();g=min(g,g1-g2);console.log(g/n); // 284 g=type(uint).max;g1=gasleft();for(i=0;i<n;i++) { t2(i,4); } g2=gasleft();g=min(g,g1-g2);console.log(g/n); // 268 } }
if
condition with ||
by a branchless expressionif (numerator > denominator || numerator == 0) { ... }
The condition can be replaced by or(iszero(numerator), gt(numerator, denominator))
which saves one jumpi
.
Context: Seaport.sol#L137
Manual review
Replace the if
statement by the equivalent code in assembly (mentioned above).
#0 - HardlyDifficult
2022-06-26T15:30:14Z
These are interesting suggestions to consider. They seem to have small impact, but go deep into the code in order to make non-obvious recommendations.