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: 2/28
Findings: 3
Award: $10,801.70
🌟 Selected for report: 2
🚀 Solo Findings: 2
🌟 Selected for report: IllIllI
8115.8498 USDC - $8,115.85
https://github.com/code-423n4/2022-02-foundation/blob/a03a7e198c1dfffb1021c0e8ec91ba4194b8aa12/contracts/mixins/NFTMarketCreators.sol#L158-L160 https://github.com/code-423n4/2022-02-foundation/blob/a03a7e198c1dfffb1021c0e8ec91ba4194b8aa12/contracts/mixins/NFTMarketCreators.sol#L196-L198 https://github.com/code-423n4/2022-02-foundation/blob/a03a7e198c1dfffb1021c0e8ec91ba4194b8aa12/contracts/mixins/NFTMarketCreators.sol#L97-L99
According to the README.md
All sales in the Foundation market will pay the creator 10% royalties on secondary sales. This is not specific to NFTs minted on Foundation, it should work for any NFT. If royalty information was not defined when the NFT was originally deployed, it may be added using the Royalty Registry which will be respected by our market contract.
Using the Royalty Registry an owner can decide to change the royalty information right before the sale is complete, affecting who gets what.
By updating the registry to include the seller as one of the royalty recipients, the creator can steal the sale price minus fees. This is because if code finds that the seller is a royalty recipient the royalties are all passed to the creator regardless of whether the owner is the seller or not.
// 4th priority: getRoyalties override if (recipients.length == 0 && nftContract.supportsERC165Interface(type(IGetRoyalties).interfaceId)) { try IGetRoyalties(nftContract).getRoyalties{ gas: READ_ONLY_GAS_LIMIT }(tokenId) returns ( address payable[] memory _recipients, uint256[] memory recipientBasisPoints ) { if (_recipients.length > 0 && _recipients.length == recipientBasisPoints.length) { bool hasRecipient; for (uint256 i = 0; i < _recipients.length; ++i) { if (_recipients[i] != address(0)) { hasRecipient = true; if (_recipients[i] == seller) { return (_recipients, recipientBasisPoints, true);
When true
is returned as the final return value above, the following code leaves ownerRev
as zero because isCreator
is true
uint256 ownerRev ) { bool isCreator; (creatorRecipients, creatorShares, isCreator) = _getCreatorPaymentInfo(nftContract, tokenId, seller); // Calculate the Foundation fee uint256 fee; if (isCreator && !_nftContractToTokenIdToFirstSaleCompleted[nftContract][tokenId]) { fee = PRIMARY_FOUNDATION_FEE_BASIS_POINTS; } else { fee = SECONDARY_FOUNDATION_FEE_BASIS_POINTS; } foundationFee = (price * fee) / BASIS_POINTS; if (creatorRecipients.length > 0) { if (isCreator) { // When sold by the creator, all revenue is split if applicable. creatorRev = price - foundationFee; } else { // Rounding favors the owner first, then creator, and foundation last. creatorRev = (price * CREATOR_ROYALTY_BASIS_POINTS) / BASIS_POINTS; ownerRevTo = seller; ownerRev = price - foundationFee - creatorRev; } } else { // No royalty recipients found. ownerRevTo = seller; ownerRev = price - foundationFee; } }
In addition, if the index of the seller in _recipients
is greater than MAX_ROYALTY_RECIPIENTS_INDEX
, then the seller is omitted from the calculation and gets zero (_sendValueWithFallbackWithdraw()
doesn't complain when it sends zero)
uint256 maxCreatorIndex = creatorRecipients.length - 1; if (maxCreatorIndex > MAX_ROYALTY_RECIPIENTS_INDEX) { maxCreatorIndex = MAX_ROYALTY_RECIPIENTS_INDEX; }
This issue does a lot of damage because the creator can choose whether and when to apply it on a sale-by-sale basis. Two other similar, but separate, exploits are available for the other blocks in _getCreatorPaymentInfo()
that return arrays but they either require a malicious NFT implementation or can only specify a static seller for which this will affect things. In all cases, not only may the seller get zero dollars for the sale, but they'll potentially owe a lot of taxes based on the 'sale' price. The attacker may or may not be the creator - creators can be bribed with kickbacks.
Code inspection
Always calculate owner/seller revenue separately from royalty revenue
#0 - HardlyDifficult
2022-03-07T12:13:46Z
This is a great discovery and a creative way for creators to abuse the system, stealing funds from a secondary sale. Thank you for reporting this.
It's a difficult one for us to address. We want to ensure that NFTs minted on our platform as a split continue to split revenue from the initial sale. We were using isCreator
from _getCreatorPaymentInfo
as our way of determining if all the revenue from a sale should go to the royalty recipients, which is a split contract for the use case we are concerned about here.
The royalty override makes it easy for a creator to choose to abuse this feature at any time. So that was our primary focus for this fix.
This is the change we have made in _getFees
:
bool isCreator = false; // lookup for tokenCreator try ITokenCreator(nftContract).tokenCreator{ gas: READ_ONLY_GAS_LIMIT }(tokenId) returns ( address payable _creator ) { isCreator = _creator == seller; } catch // solhint-disable-next-line no-empty-blocks { // Fall through } (creatorRecipients, creatorShares) = _getCreatorPaymentInfo(nftContract, tokenId);
Since the royalty override is only considered in _getCreatorPaymentInfo
we are no longer vulnerable to someone adding logic after the NFT has been released to try and rug pull the current owner(s).
It is still possible for someone to try and abuse this logic, but to do so they must have built into the NFT contract itself a way to lie about who the tokenCreator
is before the time of a sale. If we were to detect this happening, we would moderate that collection from the Foundation website. Additionally we will think about a longer term solution here so that this type of attack is strictly not possible with our market contract.
🌟 Selected for report: IllIllI
2434.7549 USDC - $2,434.75
According to the README.md
If royalty information was not defined when the NFT was originally deployed, it may be added using the Royalty Registry which will be respected by our market contract.
The actual exchange code only respects the Royalty Registry or other royalty information if the number of recipients is less than or equal to four.
If the creatorRecipients.length
is more than four then the array is essentially truncated and the royalties are only split among the first four entries in the array. If the array happens to be sorted from low to high then the people who were supposed to get the largest portions of the royalties are given nothing.
uint256 maxCreatorIndex = creatorRecipients.length - 1; if (maxCreatorIndex > MAX_ROYALTY_RECIPIENTS_INDEX) { maxCreatorIndex = MAX_ROYALTY_RECIPIENTS_INDEX; }
// Send payouts to each additional recipient if more than 1 was defined uint256 totalDistributed; for (uint256 i = 1; i <= maxCreatorIndex; ++i) { uint256 share = (creatorFee * creatorShares[i]) / totalShares; totalDistributed += share; _sendValueWithFallbackWithdraw(creatorRecipients[i], share, SEND_VALUE_GAS_LIMIT_MULTIPLE_RECIPIENTS); }
Creators shouldn't have to settle the correct amounts amongst themselves afterwards and doing so may trigger unwanted tax consequences for the creators who got the larger shares of funds.
Code inspection
Fetch the royalty information during offer creation, cache it for the final transfer, and reject any NFT for which the array size is more than MAX_ROYALTY_RECIPIENTS_INDEX
#0 - HardlyDifficult
2022-03-01T11:27:36Z
Yes, this is correct. This behavior is intended -- however we should be more clear about that in the readme and inline comments. I'll add some comments inline to clarify.
The reason we have a cap at all is because any number of addresses could be returned by these APIs. That would be okay if the creator themselves always paid the gas costs to process them, but since the costs are often pushed to the collectors or secondary sellers - we wanted to ensure the worst case scenario never got unreasonably expensive.
The actual limit we put in place is arbitrary. However some limit is necessary to ensure a reasonable experience for our users.
If the creator does want payouts to go to many different addresses, the recommended approach would be to send the royalties to a contract with then handles the split internally in a gas efficient manner, e.g. with https://www.0xsplits.xyz/
🌟 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
251.1035 USDC - $251.10
There are many places in the code where it is assumed that variables will never overflow, but just stating the assumption does not protect users from the loss of funds. There are many people (e.g. bitcoin maximalists) who believe that all other coins will inflate away to zero eventually so it's important for code to be written to handle such scenarios. Code that has underflow/overflow protection will reject the addition of new data, whereas the sponsor's code will lose users' money if that ever happens.
Using unchecked {}
for assumptions rather than certainties is not the correct use of the keyword. This is an example of the sponsor's code where unchecked {}
is used correctly:
if (msg.value < amount) { // The check above prevents underflow with delta. unchecked { uint256 delta = amount - msg.value; if (accountInfo.freedBalance < delta) { revert FETH_Insufficient_Available_Funds(accountInfo.freedBalance); } // The check above prevents underflow of freed balance. accountInfo.freedBalance -= uint96(delta); }
Given that delta is less than freedBalance which is a uint96
, it's mathematically impossible for freedBalance
to underflow.
The following are examples of code where overflow/underflow are mathematically possible, and when such events occur the user will lose his/her funds/NFTs
unchecked { pendingWithdrawals[user] += amount; }
unchecked { accountInfo.freedBalance += uint96(msg.value); }
unchecked { toAccountInfo.freedBalance += uint96(amount); }
unchecked { accountInfo.allowance[msg.sender] -= amount; }
unchecked { accountInfo.freedBalance -= uint96(amount); }
unchecked { accountInfo.freedBalance += escrow.totalAmount; accountInfo.lockups.del(escrowIndex); // Escrow index cannot overflow 32 bits. escrow = accountInfo.lockups.get(escrowIndex + 1); }
unchecked { // Increment the escrow start index if the next bucket is not empty ++escrowIndex; }
unchecked { accountInfo.lockupStartIndex = uint32(escrowIndex); }
unchecked { // The number of buckets is always < 256 bits. for (uint256 escrowIndex = accountInfo.lockupStartIndex; ; ++escrowIndex) { LockedBalance.Lockup memory escrow = accountInfo.lockups.get(escrowIndex); if (escrow.expiration == 0) { if (expiration > type(uint32).max) { revert FETH_Expiration_Too_Far_In_Future(); } // Amount (ETH) will always be < 96 bits. accountInfo.lockups.set(escrowIndex, expiration, amount); break; } if (escrow.expiration == expiration) { // Total ETH will always be < 96 bits. accountInfo.lockups.setTotalAmount(escrowIndex, escrow.totalAmount + amount); break; } }
unchecked { accountInfo.freedBalance += uint96(amount); }
unchecked { ++accountInfo.lockupStartIndex; }
unchecked { ++escrowIndex; }
Code inspection
Don't use unchecked {}
unless it's mathematically impossible for things to overflow.
#0 - HardlyDifficult
2022-03-02T16:40:51Z
This is good feedback and a very reasonable recommendation. I do like the general suggestion of Don't use unchecked {} unless it's mathematically impossible for things to overflow.
We looked these over and decided not to make any changes at this time. The unchecks mentioned above due help to save gas, particularly the ones which appear in loops. Here's an outline of our thinking behind the unchecked blocks which are mathematically possible to overflow.
#1 - alcueca
2022-03-17T09:28:24Z
Unadjusted score: 30 (low risk of something going wrong x3, which is the maximum multiplier for instances found).