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: 176/243
Findings: 2
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
github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L189-L200
MinterContract.mint
calls NextGenCore.mint
, which variables that accounts the amount of tokens each user minted is changed only after _mintProcessing
, that has a callback in _safeMint
. Because of that, attackers can reenter MinterContract.mint
before tokensMintedAllowlistAddress
and tokensMintedPerAddress
are incremented, allowing them to bypass the max allowance check that limits the amount of tokens an user can mint for public or allowlist phase of a collection.
tokensMintedPerAddress[_collectionID][attacker] = 2
(retrieved via retrieveTokensMintedPublicPerAddress
)collectionAdditionalData[_collectionID].maxCollectionPurchases = 3
(retrieved via viewMaxAllowance
)MinterContract.mint
to mint one token. Logic that executes for public phase (and no delegation, sales mode not 3):} 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"'; } else { revert("No minting"); } uint256 collectionTokenMintIndex; collectionTokenMintIndex = gencore.viewTokensIndexMin(col) + gencore.viewCirSupply(col) + _numberOfTokens - 1; require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply"); require(msg.value >= (getPrice(col) * _numberOfTokens), "Wrong ETH"); 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); }
tokensMintedPerAddress[_collectionID][attacker]
plus amount of tokens to mint and collectionAdditionalData[_collectionID].maxCollectionPurchases
.require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
3 <= 3
, which is true, so no reverts.gencore.mint
is called:function mint(uint256 mintIndex, address _mintingAddress , address _mintTo, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint256 phase) external { require(msg.sender == minterContract, "Caller is not the Minter Contract"); collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1; if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) { _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); if (phase == 1) { tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; } else { tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; } } }
_mintProcessing
, which has _safeMint
, a function with ERC721.onERC721Received
callback: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); }
function mint(uint256 mintIndex, address _mintingAddress , address _mintTo, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint256 phase) external { collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 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; } } }
ERC721.onERC721Received
callback, MinterContract.mint
is called again:function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { uint256 counter; uint256 reenter_x_times; if (counter != reenter_x_times){ target.mint{value: NFT_PRICE}(); counter++; } // ... }
MinterContract.mint
:tokensMintedAllowlistAddress[_collectionID][attacker]
isn't updated during the reentrancy, making the check 3 <= 3
again:require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
mintIndex
, because it relies on collectionCirculationSupply
, which is updated before the callback.if (phase == 1) { tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; } else { tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; } } }
tokensMintedPerAddress[_collectionID][attacker] = 4
collectionAdditionalData[_collectionID].maxCollectionPurchases = 3
Attacker successfully minted more tokens than he could. The same exploit could be executed for allowlist, since tokensMintedAllowlistAddress
is also updated only after the callback.Attacker can bypass the maximum purchases for allowlist or public phases, effectively being able to use mint
more times than a normal user, which is specially dangerous for the phase 1.
Manual Review
Use nonReentrant
modifier from ReentrancyGuard.sol
and follow the Checks-Effects-Interactions pattern.
Reentrancy
#0 - c4-pre-sort
2023-11-15T00:18:12Z
141345 marked the issue as duplicate of #2039
#1 - c4-pre-sort
2023-11-16T23:40:32Z
141345 marked the issue as duplicate of #51
#2 - c4-pre-sort
2023-11-26T14:04:30Z
141345 marked the issue as duplicate of #1742
#3 - c4-judge
2023-12-08T16:15:39Z
alex-ppg marked the issue as satisfactory
#4 - c4-judge
2023-12-08T16:15:45Z
alex-ppg marked the issue as partial-50
#5 - c4-judge
2023-12-08T19:16:59Z
alex-ppg marked the issue as satisfactory
#6 - c4-judge
2023-12-09T00:18:52Z
alex-ppg changed the severity to 3 (High Risk)