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: 4/59
Findings: 3
Award: $88,255.26
π Selected for report: 1
π Solo Findings: 0
22572.5904 USDC - $22,572.59
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.
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
63711.4668 USDC - $63,711.47
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.
Alice makes the following offer: a basic order, with two considerationItem
s. 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 pointer | correct calldata | exploit calldata |
---|---|---|
... | ... | ... |
0x204 | 1 (tot original) | 1 (tot original) |
0x224 | 0x240 (head addRec) | 0x240 (head addRec) |
0x244 | 0x2a0 (head sign) | 0x260 (head sign) |
0x264 | 1 (length addRec) | 0 (length addRec) |
0x284 | X (amount) | X (length sign) |
0x2a4 | Y (recipient) | Y (sign body) |
0x2c4 | 0x40 (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:
_assertConsiderationLengthIsNotLessThanOriginalConsiderationLength
passes;orderHash
calculated is the correct one, since the for-loop over original consideration items picks up calldata at pointers {0x284, 0x2a4} (L513);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:
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
Per @0age resolved via: https://github.com/ProjectOpenSea/seaport/pull/317
π 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
1971.1956 USDC - $1,971.20
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.
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.