Foundation contest - IllIllI's results

Building the new creative economy

General Information

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

Foundation

Findings Distribution

Researcher Performance

Rank: 2/28

Findings: 3

Award: $10,801.70

🌟 Selected for report: 2

🚀 Solo Findings: 2

Findings Information

🌟 Selected for report: IllIllI

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

8115.8498 USDC - $8,115.85

External Links

Lines of code

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

Vulnerability details

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.

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/README.md?plain=1#L21

Using the Royalty Registry an owner can decide to change the royalty information right before the sale is complete, affecting who gets what.

Impact

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.

Proof of Concept

          // 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);

https://github.com/code-423n4/2022-02-concur/blob/72b5216bfeaa7c52983060ebfc56e72e0aa8e3b0/contracts/MasterChef.sol#L127-L154

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;
        }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/mixins/NFTMarketFees.sol#L76-L79

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.

Tools Used

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.

Findings Information

🌟 Selected for report: IllIllI

Labels

bug
2 (Med Risk)
sponsor acknowledged

Awards

2434.7549 USDC - $2,434.75

External Links

Lines of code

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/mixins/NFTMarketFees.sol#L76-L79

Vulnerability details

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.

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/README.md?plain=1#L21

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.

Impact

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.

Proof of Concept

        uint256 maxCreatorIndex = creatorRecipients.length - 1;
        if (maxCreatorIndex > MAX_ROYALTY_RECIPIENTS_INDEX) {
          maxCreatorIndex = MAX_ROYALTY_RECIPIENTS_INDEX;
        }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/mixins/NFTMarketFees.sol#L76-L79

        // 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);
        }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/mixins/NFTMarketFees.sol#L99-L105

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.

Tools Used

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/

Findings Information

Awards

251.1035 USDC - $251.10

Labels

bug
QA (Quality Assurance)

External Links

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);
      }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L534-L543

Given that delta is less than freedBalance which is a uint96, it's mathematically impossible for freedBalance to underflow.

Impact

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

Proof of Concept

      unchecked {
        pendingWithdrawals[user] += amount;
      }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/mixins/SendValueWithFallbackWithdraw.sol#L72-L74

    unchecked {
      accountInfo.freedBalance += uint96(msg.value);
    }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L238-L240

    unchecked {
      toAccountInfo.freedBalance += uint96(amount);
    }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L385-L387

      unchecked {
        accountInfo.allowance[msg.sender] -= amount;
      }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L447-L449

    unchecked {
      accountInfo.freedBalance -= uint96(amount);
    }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L462-L464

      unchecked {
        accountInfo.freedBalance += escrow.totalAmount;
        accountInfo.lockups.del(escrowIndex);
        // Escrow index cannot overflow 32 bits.
        escrow = accountInfo.lockups.get(escrowIndex + 1);
      }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L482-L487

      unchecked {
        // Increment the escrow start index if the next bucket is not empty
        ++escrowIndex;
      }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L495-L498

    unchecked {
      accountInfo.lockupStartIndex = uint32(escrowIndex);
    }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L507-L509

    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;
        }
      }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L550-L567

    unchecked {
      accountInfo.freedBalance += uint96(amount);
    }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L583-L585

          unchecked {
            ++accountInfo.lockupStartIndex;
          }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L613-L615

        unchecked {
          ++escrowIndex;
        }

https://github.com/code-423n4/2022-02-foundation/blob/4d8c8931baffae31c7506872bf1100e1598f2754/contracts/FETH.sol#L630-L632

Tools Used

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.

  • ETH: uint96
    • Circulating supply is currently 119,440,269 ETH (119440269000000000000000000 wei / 1.2 * 10^26).
    • Max uint96 is 7.9 * 10^28.
    • Therefore any value capped by msg.value should never overflow uint96 assuming ETH total supply remains under 70,000,000,000 ETH.
      • There is currently ~2% inflation in ETH total supply (which fluctuates) and this value is expected to do down. We expect Ether will become deflationary before the supply reaches more than 500x current values.
    • 96 bits packs perfectly into a single slot with an address.
  • Dates: uint32
    • Current date in seconds is 1643973923 (1.6 * 10^9).
    • Max uint32 is 4 * 10^9.
    • Dates will not overflow uint32 until 2104.
    • To ensure we don't behave unexpectedly in the future, we should require dates are <= max uint32.
    • uint40 would allow enough space for any date, but it's an awkward size to pack with.
  • Sequence ID indexes: uint32
    • Our max sequence ID today is 149,819 auctions.
    • Max uint32 is 4 * 10^9.
    • Indexes will not overflow uint32 until we see >28,000x growth. This is the equiv to ~300 per block for every block in Ethereum to date.

#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).

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter