Foundation Drop contest - 0x1f8b's results

Foundation is a web3 destination.

General Information

Platform: Code4rena

Start Date: 11/08/2022

Pot Size: $40,000 USDC

Total HM: 8

Participants: 108

Period: 4 days

Judge: hickuphh3

Total Solo HM: 2

Id: 152

League: ETH

Foundation

Findings Distribution

Researcher Performance

Rank: 13/108

Findings: 3

Award: $196.91

๐ŸŒŸ Selected for report: 0

๐Ÿš€ Solo Findings: 0

Awards

42.8343 USDC - $42.83

Labels

bug
duplicate
2 (Med Risk)

External Links

Lines of code

https://github.com/code-423n4/2022-08-foundation/blob/792e00df429b0df9ee5d909a0a5a6e72bd07cf79/contracts/mixins/nftDropMarket/NFTDropMarketFixedPriceSale.sol#L183

Vulnerability details

Impact

The NFTDropMarketFixedPriceSale contract contains a very easy to bypass constraint.

Proof of Concept

The mintFromFixedPriceSale method checks that the balance after the user's mint is not greater than that configured in saleConfig.limitPerAccount.

    // Confirm that the buyer will not exceed the limit specified after minting.
    if (IERC721(nftContract).balanceOf(msg.sender) + count > saleConfig.limitPerAccount) {
      revert ...;
    }

But this is easy to avoid if prior to the call of mintFromFixedPriceSale the balance is sent to another account, since control of how much said account carries is out of our control, since the counter is controlled by the sender, not by the contract.

  • Use a counter that keeps the sum of the total number of minted tokens per account.

#0 - HardlyDifficult

2022-08-17T20:58:12Z

Low

1. Outdated compiler

The pragma version used is:

pragma solidity ^0.8.12;

The minimum required version must be 0.8.15; otherwise, contracts will be affected by the following important bug fixes:

0.8.13:

  • Code Generator: Correctly encode literals used in abi.encodeCall in place of fixed bytes arguments.

0.8.14:

  • ABI Encoder: When ABI-encoding values from calldata that contain nested arrays, correctly validate the nested array length against calldatasize() in all cases.
  • Override Checker: Allow changing data location for parameters only when overriding external functions.

0.8.15

  • Code Generation: Avoid writing dirty bytes to storage when copying bytes arrays.
  • Yul Optimizer: Keep all memory side-effects of inline assembly blocks.

Apart from these, there are several minor bug fixes and improvements.

2. Upgradable contracts without GAP can lead a upgrade disaster

Some contract does not implement a good upgradeable logic.

Proxied contracts MUST include an empty reserved space in storage, usually named __gap, in all the inherited contracts.

For example, if it is an interface, you have to use the interface keyword, otherwise it is a contract that requires your GAP to be correctly updated, so if a new variable is created it could lead in storage collisions that will produce a contract dissaster, including loss of funds.

Reference:

MarketSharedCore has a gap because it will be upgradeable but the base class FETHNode don't.

Affected source code:

The same problem occurred with AdminRole and MinterRole, both should have a gap, but only AdminRole has.

Affected source code:

Other upgradable contracts without a gap have been found:

3. Wrong gap desing

The inheritance of Gap10000 is a bad design to have gaps, the gaps have to be defined in the contract itself, otherwise if a variable is added, it has to be modified in the base contract, affecting all the contracts that inherit it.

Affected source code:

The same behavior is observed with NFTDropMarketCore:

4. Admin can burn tokens

Using the burn method, the admin can burn an arbitrary amount of tokens from any address.

I believe this is unnecessary and poses a serious centralization risk. A malicious or compromised admin can take advantage of this.

Consider limiting the burn function to the token owner.

Affected source code:

5. tokenURI could return a wrong uri

The baseURI state variable allows it to be hidden by the admin and revealed via the reveal method.

If the baseURI has not been revealed, the tokenURI method will return an incorrect URL.

The following modifications are recommended:

  function tokenURI(uint256 tokenId) public view override returns (string memory uri) {
+   require(bytes(_baseURI).length != 0, "NFTDropCollection: require reveal");
    _requireMinted(tokenId);

    return string.concat(baseURI, tokenId.toString(), ".json");
  }

Affected source code:


Non-Critical

6. Improvable library design

The design of the replaceAtIf method forces the transaction to be reversed if the content to be replaced is not found. It would be more useful and versatile if a boolean was returned indicating whether the replacement was successful or not.

Affected source code:

7. Integer overflow in BytesLibrary

Although it is not vulnerable, it is not convenient to make such large unchecked blocks, on this occasion the only thing that prevents the overflow in startLocation + i is that it will not be possible to send a data long enough to exploit it.

The following modifications are recommended:

  function replaceAtIf(
    bytes memory data,
    uint256 startLocation,
    address expectedAddress,
    address newAddress
  ) internal pure {
    bytes memory expectedData = abi.encodePacked(expectedAddress);
    bytes memory newData = abi.encodePacked(newAddress);
-   unchecked {
      // An address is 20 bytes long
-     for (uint256 i = 0; i < 20; ++i) {
+     for (uint256 i; i < 20;) {
        uint256 dataLocation = startLocation + i;
        if (data[dataLocation] != expectedData[i]) {
          revert BytesLibrary_Expected_Address_Not_Found();
        }
        data[dataLocation] = newData[i];
+       unchecked { ++i; }       
      }
-   }
  }

Affected source code:

8. Open TODO

The code that contains "open todos" reflects that the development is not finished and that the code can change a posteriori, prior release, with or without audit.

Affected source code:

#0 - HardlyDifficult

2022-08-18T21:12:43Z

Outdated compiler

None of those bugs seem to apply to our code base. We intentionally use a floating pragma in order to make integrating with contracts easier. Other contract developers are looking to interact with our contracts and they may be on a different version than we use. The pragma selected for our contracts is the minimum required in order to correctly compile and function. This way integration is easier if they lag a few versions behind, or if they use the latest but we don't bump our packages frequently enough, and when we do upgrade versions unless there was a breaking solidity change -- it should just swap in by incrementing our npm package version.

Upgradable contracts without GAP can lead a upgrade disaster

Some of our stateless contracts do not include gaps because we intend on keeping them stateless.

Collections are not upgradeable so gaps are not required.

Wrong gap desing

This is just an approach to leave a large amount of space for unknown mixins to be added in the middle of our inheritance tree in the future.

  1. Admin can burn tokens

Invalid. NFTs can only be burned if it is owned by the admin

tokenURI could return a wrong uri

Invalid. We want tokenURI to return the pre-reveal image provided in this scenario. baseURI is always required.

  1. Improvable library design

That's fair feedback, but for the use case we have for that function -- reverting is desired.

Integer overflow in BytesLibrary

Good feedback, agree and will fix.

Unresolved TODO comments

Agree, will fix.

Gas

1. Unused import

Importing pointless files costs gas during deployment and is a bad coding practice that is important to ignore.

Remove the following imports:

2. constants expressions are expressions, not constants

Due to how constant variables are implemented (replacements at compile-time), an expression assigned to a constant variable is recomputed each time that the variable is used, which wastes some gas.

If the variable was immutable instead: the calculation would only be done once at deploy time (in the constructor), and then the result would be saved and read directly at runtime rather than being recalculated.

Reference:

Consequences: each usage of a "constant" costs ~100gas more on each access (it is still a little better than storing the result in storage, but not much..). since these are not real constants, they can't be referenced from a real constant environment (e.g. from assembly, or from another library )

Affected source code:

3. Reduce the size of error messages (Long revert Strings)

Shortening revert strings to fit in 32 bytes will decrease deployment time gas and will decrease runtime gas when the revert condition is met.

Revert strings that are longer than 32 bytes require at least one additional mstore, along with additional overhead for computing memory offset, etc.

Proof of concept (without optimizations):

pragma solidity 0.8.15;

contract TesterA {
  function testShortRevert(bool path) public view { 
    require(path, "test error");
  }
}

contract TesterB {
  function testLongRevert(bool path) public view { 
    require(path, "test big error message, more than 32 bytes");
  }
}

Gas saving executing: 18 per entry

TesterA.testShortRevert: 21886 TesterB.testLongRevert: 21904

Affected source code:

4. Use Custom Errors instead of Revert Strings to save Gas

Custom errors from Solidity 0.8.4 are cheaper than revert strings (cheaper deployment cost and runtime cost when the revert condition is met)

Source Custom Errors in Solidity:

Starting from Solidity v0.8.4, there is a convenient and gas-efficient way to explain to users why an operation failed through the use of custom errors. Until now, you could already use strings to give more information about failures (e.g., revert("Insufficient funds.");), but they are rather expensive, especially when it comes to deploy cost, and it is difficult to use dynamic information in them.

Custom errors are defined using the error statement, which can be used inside and outside of contracts (including interfaces and libraries).

Proof of concept (without optimizations):

pragma solidity 0.8.15;

contract TesterA {
  function testRevert(bool path) public view { 
    require(path, "test error");
  }
}

contract TesterB {
  error MyError(string msg);

  function testError(bool path) public view {
     if(path) revert MyError("test error");
  }
}

Gas saving executing: 9 per entry

TesterA.testRevert: 21611 TesterB.testError: 21602

Affected source code:

5. There's no need to set default values for variables

If a variable is not set/initialized, the default value is assumed (0, false, 0x0 ... depending on the data type). You are simply wasting gas if you directly initialize it with its default value.

Proof of concept (without optimizations):

pragma solidity 0.8.15;

contract TesterA {
  function testInit() public view returns (uint) { uint a = 0; return a; }
}

contract TesterB {
  function testNoInit() public view returns (uint) { uint a; return a; }
}

Gas saving executing: 8 per entry

TesterA.testInit: 21392 TesterB.testNoInit: 21384

Affected source code:

6. Use unckecked region

It's possible to save gas using the unckecked keyword around a math operation. This will avoid the required checks to ensure that the variable won't overflow.

Reference:

Proof of concept (without optimizations):

pragma solidity 0.8.15;

contract TesterA {
  function testUnChecked(uint a, uint b) public returns (uint) { unchecked { return a - b; } }
}

contract TesterB {
  function testChecked(uint a, uint b) public returns (uint) { return a - b; }
}

Gas saving executing: 182 per entry

TesterA.testUnChecked: 22103 TesterB.testChecked: 22285

Affected source code:

7. Remove event index

The following values are considered unnecessary to index and may incur unnecessary overhead.

Affected source code:

8. Remove unnecessary inheritance

The NFTDropMarketCore contract does not provide any logic, only a gap in the style of the Gap10000 contract, these gaps are badly designed since if the class that inherits them modifies the number of slots used, modifying the base class will affect all the contracts that inherit them.

It's recommended to delete both base contracts:

Affected source code:

9. Reduce logic in NFTDropMarketFixedPriceSale

The _getSellerOf method calls the getFixedPriceSale method and runs totally unnecessary logic like checking if you have the right to mint (marketCanMint).

The following modifications are recommended:

  function _getSellerOf(
    address nftContract,
    uint256 /* tokenId */
  ) internal view virtual override returns (address payable seller) {
-   (seller, , , , ) = getFixedPriceSale(nftContract);
+   try INFTDropCollectionMint(nftContract).numberOfTokensAvailableToMint() returns (uint256 count) {
+     if (count != 0) {
+       seller = nftContractToFixedPriceSaleConfig[nftContract].seller;
+     }
+   } catch // solhint-disable-next-line no-empty-blocks
+   {
+     // Contract not supported - return default values
+   }
  }

Affected source code:

10. use calldata instead of memory

The method createNFTDropCollectionWithPaymentFactory is external, but the argument paymentAddressFactoryCall was defined as memory instead of as calldata.

The following modifications are recommended:

  function createNFTDropCollectionWithPaymentFactory(
    string calldata name,
    string calldata symbol,
    string calldata baseURI,
    bytes32 postRevealBaseURIHash,
    uint32 maxTokenId,
    address approvedMinter,
    uint256 nonce,
-   CallWithoutValue memory paymentAddressFactoryCall
+   CallWithoutValue calldata paymentAddressFactoryCall
  ) external returns (address collection) {

Affected source code:

11. Use abi.encode instead of abi.encodePacked

abi.encodePacked has a cost difference with respect to abi.encode depending on the types it uses, in the case of the reference shown below you can see that the type address is affected, so I have decided to do a test on it.

Also there is a discussion about removing abi.encodePacked from future versions of Solidity (ethereum/solidity#11593), so using abi.encode now will ensure compatibility in the future.

Reference:

Proof of concept (without optimizations):

pragma solidity ^0.8.12;

contract TesterA {
   function getSaltEncode(address creator, uint256 nonce) public pure returns (bytes32) {
    return keccak256(abi.encode(creator, nonce));
  }
}

contract TesterB {
   function getSaltEncodePacked(address creator, uint256 nonce) public pure returns (bytes32) {
    return keccak256(abi.encodePacked(creator, nonce));
  }
}

Gas saving: 188

TesterA.getSaltEncode: 22774 TesterB.getSaltEncodePacked: 22962

It's recommended to apply the following changes:

  function _getSalt(address creator, uint256 nonce) private pure returns (bytes32) {
-   return keccak256(abi.encodePacked(creator, nonce));
+   return keccak256(abi.encode(creator, nonce));
  }

Affected source code:

12. > 0 is less efficient than != 0 for unsigned integers

Although it would appear that > 0 is less expensive than != 0, this is only true in the absence of the optimizer and outside of a need statement. Gas will be saved if the optimizer is enabled at 10k and you're in a require statement.

Reference:

Affected source code:

13. Don't use the length of an array for loops condition

It's cheaper to store the length of the array inside a local variable and iterate over it.

Affected source code:

14. ++i costs less gas compared to i++ or i += 1

++i costs less gas compared to i++ or i += 1 for unsigned integer, as pre-increment is cheaper (about 5 gas per iteration). This statement is true even with the optimizer enabled.

i++ increments i and returns the initial value of i. Which means:

uint i = 1;
i++; // == 1 but i == 2

But ++i returns the actual incremented value:

uint i = 1;
++i; // == 2 and i == 2 too, so no need for a temporary variable

In the first case, the compiler has to create a temporary variable (when used) for returning 1 instead of 2 I suggest using ++i instead of i++ to increment the value of an uint variable. Same thing for --i and i--

Affected source code:

#0 - HardlyDifficult

2022-08-17T14:31:34Z

Good report! Well formatted, easy to read & has useful supporting remarks.

  1. Unused import

Agree, fixed. This probably should have been in your QA report.

  1. constants expressions are expressions, not constants

While it seems this should help, changing to immutable shows a regression in our test suite. Gas costs go up by ~50 gas.

  1. Use short error messages

Agree but won't fix. We use up to 64 bytes, aiming to respect the incremental cost but 32 bytes is a bit too short to provide descriptive error messages for our users.

  1. Custom errors

Agree but won't fix at this time. We use these in the market but not in collections. Unfortunately custom errors are still not as good of an experience for users (e.g. on etherscan). We used them in the market originally because we were nearing the max contract size limit and this was a good way to reduce the bytecode. We'll consider this in the future as tooling continues to improve.

  1. Don't initialize variables with default values.

Invalid. This optimization technique is no longer applicable with the current version of Solidity.

  1. Unchecked

2 of the 3 links are already unchecked - so those are invalid. getFeesAndRecipients is a read only function not intended to be used on-chain, but as a best practice we will add unchecked there as well.

  1. Remove event index

Good consideration. We'll remove indexed for MaxTokenIdUpdated but keep the others, I could envision use cases for those.

  1. Remove unnecessary inheritance

Disagree. Having free slots helps to maintain compatibility when adding new functionality to an upgradeable contract. Also this is QA not gas feedback.

  1. Reduce logic in NFTDropMarketFixedPriceSale

Valid but won't fix for now. This getter is just for frontend app consumption ATM so gas is not important and acknowledging scenarios like right to mint may be a nice to have there. We may rework this in the future.

  1. calldata

Valid & will fix. This saves ~60 gas on createNFTDropCollectionWithPaymentFactory

  1. encode

Invalid... while it seems like this should help, changing to .encode makes creating collections slightly more expensive. createNFTCollection increased by 9 gas and createNFTDropCollection increased by 8-10 gas

  1. Use != 0 instead of > 0

Invalid. We tested the recommendation and got the following results:

createNFTDropCollection gas reporter results: using > 0 (current): - 319246 ยท 319578 ยท 319361 using != 0 (recommendation): - 319252 ยท 319584 ยท 319367 impact: +6 gas
  1. Cache Array Length Outside of Loop

May be theoretically valid, but won't fix. I tested this: gas-reporter and our gas-stories suite is reporting a small regression using this technique. It also hurts readability a bit so we wouldn't want to include it unless it was a clear win.

  1. ++i costs less than i++

Agree and will fix.

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