Platform: Code4rena
Start Date: 24/02/2022
Pot Size: $75,000 USDC
Total HM: 21
Participants: 28
Period: 7 days
Judge: alcueca
Total Solo HM: 15
Id: 94
League: ETH
Rank: 3/28
Findings: 6
Award: $10,603.80
🌟 Selected for report: 4
🚀 Solo Findings: 4
🌟 Selected for report: thankthedark
Also found by: Afanasyevich, cmichel
The private sale signature used in buyFromPrivateSaleFor
does not prevent replay attacks.
If the NFT ends up in the original seller's wallet again (within the deadline) it can be purchased from them again.
buyFromPrivateSaleFor(..., v, r, s)
to buy it from S a second time at the lower initial price. S loses their NFT and B could list it for a higher price again.Use a nonce
, indexed by (nftContract, tokenId)
, as part of the message to be signed. Increase the nonce on each buyFromPrivateSaleFor
.
Indexing the nonce by (nftContract, tokenId)
ensures that each signature is only valid for a private sale of that NFT once.
- keccak256(abi.encode(BUY_FROM_PRIVATE_SALE_TYPEHASH, nftContract, tokenId, msg.sender, amount, deadline)) + keccak256(abi.encode(BUY_FROM_PRIVATE_SALE_TYPEHASH, nftContract, tokenId, msg.sender, amount, deadline, nonces[nftContract][tokenId]++))
#0 - HardlyDifficult
2022-03-02T16:24:17Z
#1 - alcueca
2022-03-14T11:36:48Z
Agree with a Medium severity risk, due to the very particular set of conditions for this attack to happen.
🌟 Selected for report: cmichel
2434.7549 USDC - $2,434.75
The FETH.withdrawFrom
function does not validate its to
parameter.
Funds can be lost if to
is the zero address.
Similar issues have been judged as medium recently, see Sandclock M-15 / Github issue
Check that to != 0
.
#0 - HardlyDifficult
2022-03-02T20:55:34Z
Yes, this is a good recommendation which may prevent user's losing their funds due to user error. We have made the following change as recommended:
} else if (to == address(0)) { revert FETH_Cannot_Withdraw_To_Address_Zero(); } else if (to == address(this)) { revert FETH_Cannot_Withdraw_To_FETH(); }
🌟 Selected for report: cmichel
2434.7549 USDC - $2,434.75
The LockedBalance
contract takes 256-bit amount values but performs bit math on them as if they were 96 bit values.
Bits could spill over to a different locked balance in the else
part (lockedBalance
stores two 128-bit locked balances in one 256-bit storage field):
function set( Lockups storage lockups, uint256 index, uint256 expiration, uint256 totalAmount ) internal { unchecked { // @audit-issue should drop totalAmount to 96, expiration to 128-96=32 uint256 lockedBalanceBits = totalAmount | (expiration << 96); if (index % 2 == 0) { // set first 128 bits. index /= 2; // @audit-info clears upper bits, then sets them lockups.lockups[index] = (lockups.lockups[index] & last128BitsMask) | (lockedBalanceBits << 128); } else { // set last 128 bits. index /= 2; // @audit-info clears lower bits, then sets them // @audit-issue sets entire 256-bit lockedBalanceBits instead of just 128-bit lockups.lockups[index] = (lockups.lockups[index] & first128BitsMask) | lockedBalanceBits; } } }
It could then increase the other, unrelated locked balance's amount leading to stealing funds from the protocol.
All callers of this function currently seem to ensure that totalAmount
is indeed less than 96 bits but the LockedBalance
library should be self-contained and not depend on the calling side to perform all checks.
If the code is ever extended and more calls to these functions are performed, it'll likely cause issues.
The same issue happens in
setTotalAmount
Make sure that there are only 96/32 bits set in totalAmount
and expiration
by dropping them to their respective types.
function set( Lockups storage lockups, uint256 index, uint256 expiration, uint256 totalAmount ) internal { unchecked { - uint256 lockedBalanceBits = totalAmount | (expiration << 96); + // cast it to uint256 again for the << 96 to work on 256-bits + uint256 lockedBalanceBits = uint256(uint96(totalAmount)) | (uint256(uint32(expiration)) << 96); ... } }
#0 - HardlyDifficult
2022-03-07T12:23:54Z
This is a good concern to flag. With this release we use a lot of slot packing strategies to try and save gas. We considered this suggestion, as well as potentially using the safe cast helpers from the OpenZeppelin library. For the moment at least, we decided not to make any changes here. We believe the assumptions behind these values are sound, and although it's mathematically possible for the assumptions to be violated, we don't believe it'll occur in production.
For totalAmount
for example, that value is always bound by the amount of ETH a user has sent to the FETH contract. Here are some notes we have written up about the size assumptions in our codebase:
🌟 Selected for report: cmichel
2434.7549 USDC - $2,434.75
The creator payouts are capped at MAX_ROYALTY_RECIPIENTS_INDEX
. It's currently set to 4
and only 5 creators are paid out.
Other creators are ignored.
I don't think cases with more than 5 creators / royalty receivers are unlikely. It can and should probably be increased, especially as the transfers are already gas restricted.
#0 - HardlyDifficult
2022-03-02T21:04:21Z
Yes this is a fair point. The limit we put in place is arbitrary. We want some limit in order to ensure that the gas costs (which are often pushed to users other than the original creator) never get to be very expensive.
What we can and should do is document this limitation so that it's more clear what exactly will happen when too many recipients are defined. Additionally we should comment on a possible workaround: If you have a contract that splits with too many participants you could use the Royalty Override in order to define a single contract recipient instead to handle the splits - e.g. https://www.0xsplits.xyz/
🌟 Selected for report: cmichel
2434.7549 USDC - $2,434.75
Similar to spoofing in finance, users can create private sales with correct signatures but then frontrun the buy with a transfer to a different wallet they control.
No funds are lost as this the NFT <> FETH exchange is atomic but it can be bad if third parties create a naive off-chain centralized NFT market based on this signature feature. It's also frustrating for the users if they try to accept the private sale but their transaction fails.
This is made possible because private sales do not keep the NFT in escrow. Consider escrowing the NFT also for private sales.
#0 - HardlyDifficult
2022-03-02T21:07:54Z
We are actually considering moving private sales to an escrow system. But we'll leave it as-is for now for backwards compatibility and to simplify our upcoming launch.
We understand this could happen and will update the comments to make that more clear.
🌟 Selected for report: leastwood
Also found by: 0x1f8b, 0xliumin, 0xwags, CertoraInc, Dravee, IllIllI, Ruhum, TerrierLover, WatchPug, cmichel, csanuragjain, defsec, gzeon, hubble, jayjonah8, kenta, kirk-baird, rfa, robee
207.419 USDC - $207.42
FETH._deductAllowanceFrom
does not emit an Approval
event. It's not strictly required by EIP20 for transferFrom
calls but without it off-chain scripts cannot correctly track the actual approvals. (It's also done in the OZ implementation)FETH._freeFromEscrow
: comment should say: "if bucket NOT yet expired, that's the new start index"NFTMarketReserveAuction._transferFromEscrow
: comment should say: "If the auction has ended, the highest bidder will be the new owner" as this reverts if auction has not endedNFTMarketFees
: misleading naming. It's an amount, not a share as in creatorShares
or totalShares
. How about fee
or royalty
?NFTMarketOffer.makeOffer
: The error should be "cannot be created" instead of "cannot be accepted" because this is an error in makeOffer
, not in acceptOffer
.#0 - HardlyDifficult
2022-03-07T12:39:44Z
royalty
and also updated the total to totalRoyaltiesDistributed
to be more explicit.NFTMarketOffer_Cannot_Be_Made_While_In_Auction
#1 - alcueca
2022-03-17T09:38:14Z
Unadjusted score: 20