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: 35/243
Findings: 4
Award: $311.47
🌟 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.076 USDC - $0.08
Checks-Effects-Interactions Pattern is not followed. Reentrancy is possible with safeMint
and (allowlist or public) mint limit can be bypassed.
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; } } }
As above, tokensMintedAllowlistAddress
and tokensMintedPerAddress
is incremented after _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); }
Inside _mintProcessing
, _safeMint
will call onERC721Received
of _recipient
, which makes reentrancy possible.
function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable { ...... if (block.timestamp >= collectionPhases[col].allowlistStartTime && block.timestamp <= collectionPhases[col].allowlistEndTime) { phase = 1; bytes32 node; if (_delegator != 0x0000000000000000000000000000000000000000) { bool isAllowedToMint; isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(_delegator, 0x8888888888888888888888888888888888888888, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(_delegator, 0x8888888888888888888888888888888888888888, msg.sender, 2); if (isAllowedToMint == false) { isAllowedToMint = dmc.retrieveGlobalStatusOfDelegation(_delegator, collectionPhases[col].delAddress, msg.sender, 1) || dmc.retrieveGlobalStatusOfDelegation(_delegator, collectionPhases[col].delAddress, msg.sender, 2); } require(isAllowedToMint == true, "No delegation"); node = keccak256(abi.encodePacked(_delegator, _maxAllowance, tokData)); require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, _delegator) + _numberOfTokens, "AL limit"); mintingAddress = _delegator; } 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'); } 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"); } ...... 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); } }
Inside mint
, _maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, _delegator) + _numberOfTokens
and gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col)
is checked before calling gencore.mint
, so attacker can reenter minter.mint
inside onERC721Received
to bypass the allowlist or public mint limit.
Manual
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) { 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-16T01:04:22Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:03:06Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:26:29Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:27:08Z
alex-ppg marked the issue as partial-50
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L105 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L125
At AuctionEndTime
, both claimAuction
and cancelBid
are possible. Attacker can reenter to get the NFT while drain the contract's balance.
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); auctionClaim[_tokenid] = true; uint256 highestBid = returnHighestBid(_tokenid); address ownerOfToken = IERC721(gencore).ownerOf(_tokenid); address highestBidder = returnHighestBidder(_tokenid); for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) { if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) { IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); (bool success, ) = payable(owner()).call{value: highestBid}(""); emit ClaimAuction(owner(), _tokenid, success, highestBid); } else if (auctionInfoData[_tokenid][i].status == true) { (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); } else {} } } function cancelBid(uint256 _tokenid, uint256 index) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true); auctionInfoData[_tokenid][index].status = false; (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}(""); emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid); }
As above, claimAuction
requires block.timestamp >= minter.getAuctionEndTime(_tokenid)
and cancelBid
requires block.timestamp <= minter.getAuctionEndTime(_tokenid)
. Both actions are possible when block.timestamp == minter.getAuctionEndTime(_tokenid)
. As a result, the highest bidder can call claimAuction
at this timestamp, and safeTransferFrom
will call onERC721Received
of the highest bidder. Then, the highest bidder calls cancelBid
inside onERC721Received
, since status
will not be set to false
during claimAuction
, the call will succeed, which means the highest bidder gets both the NFT and the refund.
Although claimAuction
cannot be called by other bidders, the attacker can make multiple bids by multiple contracts. The highest bidder calls cancelBid
inside onERC721Received
and the rest call cancelBid
inside fallback
. As a result, the rest of bidders will get double refunds and drain the balance of auction contract.
Manual
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ require(block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); ......
Reentrancy
#0 - c4-pre-sort
2023-11-14T13:49:08Z
141345 marked the issue as duplicate of #1370
#1 - c4-pre-sort
2023-11-14T13:50:12Z
141345 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-11-14T13:50:28Z
141345 marked the issue as duplicate of #289
#3 - c4-pre-sort
2023-11-14T23:32:26Z
141345 marked the issue as duplicate of #962
#4 - c4-judge
2023-12-04T21:41:05Z
alex-ppg marked the issue as duplicate of #1323
#5 - c4-judge
2023-12-08T18:05:18Z
alex-ppg marked the issue as satisfactory
35.614 USDC - $35.61
Exponential Descending Sale: At each time period (which can vary), the minting cost decreases exponentially until it reaches its resting price (ending minting cost) and stays there until the end of the minting phase. Linear Descending Sale: At each time period (which can vary), the minting cost decreases linearly until it reaches its resting price (ending minting cost) and stays there until the end of the minting phase.
During a Descending Sale, if an unlucky user mints at the last second, the price will not be the resting price but the initial price. The user will overpay.
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"';
As above, the last available timestamp to mint is publicEndTime
, and there could be some speculators deliberately minting at the last moment.
function getPrice(uint256 _collectionId) public view returns (uint256) { uint tDiff; if (collectionPhases[_collectionId].salesOption == 3) { // increase minting price by mintcost / collectionPhases[_collectionId].rate every mint (1mint/period) // to get the price rate needs to be set if (collectionPhases[_collectionId].rate > 0) { return collectionPhases[_collectionId].collectionMintCost + ((collectionPhases[_collectionId].collectionMintCost / collectionPhases[_collectionId].rate) * gencore.viewCirSupply(_collectionId)); } else { return collectionPhases[_collectionId].collectionMintCost; } } else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp > collectionPhases[_collectionId].allowlistStartTime && block.timestamp < collectionPhases[_collectionId].publicEndTime) { // decreases exponentially every time period // collectionPhases[_collectionId].timePeriod sets the time period for decreasing the mintcost // if just public mint set the publicStartTime = allowlistStartTime // if rate = 0 exponetialy decrease // if rate is set the linear decrase each period per rate tDiff = (block.timestamp - collectionPhases[_collectionId].allowlistStartTime) / collectionPhases[_collectionId].timePeriod; uint256 price; uint256 decreaserate; if (collectionPhases[_collectionId].rate == 0) { price = collectionPhases[_collectionId].collectionMintCost / (tDiff + 1); decreaserate = ((price - (collectionPhases[_collectionId].collectionMintCost / (tDiff + 2))) / collectionPhases[_collectionId].timePeriod) * ((block.timestamp - (tDiff * collectionPhases[_collectionId].timePeriod) - collectionPhases[_collectionId].allowlistStartTime)); } else { if (((collectionPhases[_collectionId].collectionMintCost - collectionPhases[_collectionId].collectionEndMintCost) / (collectionPhases[_collectionId].rate)) > tDiff) { price = collectionPhases[_collectionId].collectionMintCost - (tDiff * collectionPhases[_collectionId].rate); } else { price = collectionPhases[_collectionId].collectionEndMintCost; } } if (price - decreaserate > collectionPhases[_collectionId].collectionEndMintCost) { return price - decreaserate; } else { return collectionPhases[_collectionId].collectionEndMintCost; } } else { // fixed price return collectionPhases[_collectionId].collectionMintCost; } }
When block.timestamp
is publicEndTime
, else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp > collectionPhases[_collectionId].allowlistStartTime && block.timestamp < collectionPhases[_collectionId].publicEndTime)
will not be satisfied, so the price will be the initial collectionMintCost
, which is higher than the collectionEndMintCost
. The user overpays.
Manual
} else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp >= collectionPhases[_collectionId].allowlistStartTime && block.timestamp <= collectionPhases[_collectionId].publicEndTime){
Timing
#0 - c4-pre-sort
2023-11-16T01:40:51Z
141345 marked the issue as duplicate of #1391
#1 - c4-judge
2023-12-08T21:40:55Z
alex-ppg marked the issue as partial-50
🌟 Selected for report: The_Kakers
Also found by: 0xblackskull, BugzyVonBuggernaut, Draiakoo, Stryder, VAD37, alexfilippov314, mrudenko, rotcivegaf, xAriextz, xuwinnie, zach
275.7777 USDC - $275.78
In NextGen, artists can sign their collections with a string. However, two critical invariants do not always hold in current implementation:
artistsSignatures
should only be signed by the collectionArtistAddress
.function artistSignature(uint256 _collectionID, string memory _signature) public { require(msg.sender == collectionAdditionalData[_collectionID].collectionArtistAddress, "Only artist"); require(artistSigned[_collectionID] == false, "Already Signed"); artistsSignatures[_collectionID] = _signature; artistSigned[_collectionID] = true; }
function setCollectionData(uint256 _collectionID, address _collectionArtistAddress, uint256 _maxCollectionPurchases, uint256 _collectionTotalSupply, uint _setFinalSupplyTimeAfterMint) public CollectionAdminRequired(_collectionID, this.setCollectionData.selector) { require((isCollectionCreated[_collectionID] == true) && (collectionFreeze[_collectionID] == false) && (_collectionTotalSupply <= 10000000000), "err/freezed"); if (collectionAdditionalData[_collectionID].collectionTotalSupply == 0) { collectionAdditionalData[_collectionID].collectionArtistAddress = _collectionArtistAddress; collectionAdditionalData[_collectionID].maxCollectionPurchases = _maxCollectionPurchases; collectionAdditionalData[_collectionID].collectionCirculationSupply = 0; collectionAdditionalData[_collectionID].collectionTotalSupply = _collectionTotalSupply; collectionAdditionalData[_collectionID].setFinalSupplyTimeAfterMint = _setFinalSupplyTimeAfterMint; collectionAdditionalData[_collectionID].reservedMinTokensIndex = (_collectionID * 10000000000); collectionAdditionalData[_collectionID].reservedMaxTokensIndex = (_collectionID * 10000000000) + _collectionTotalSupply - 1; wereDataAdded[_collectionID] = true; } else if (artistSigned[_collectionID] == false) { collectionAdditionalData[_collectionID].collectionArtistAddress = _collectionArtistAddress; collectionAdditionalData[_collectionID].maxCollectionPurchases = _maxCollectionPurchases; collectionAdditionalData[_collectionID].setFinalSupplyTimeAfterMint = _setFinalSupplyTimeAfterMint; } else { collectionAdditionalData[_collectionID].maxCollectionPurchases = _maxCollectionPurchases; collectionAdditionalData[_collectionID].setFinalSupplyTimeAfterMint = _setFinalSupplyTimeAfterMint; } }
As the second if else branch suggests, collectionArtistAddress
should only be modifiable when artistSigned
is false. However, a bad collection admin can do the following to bypass the check:
setCollectionData
with {_collectionArtistAddress: badAdmin, _collectionTotalSupply: 0}
artistSignature
with {_signature: fakeSignature}
setCollectionData
with {_collectionArtistAddress: realArtist, _collectionTotalSupply: realSupply}
In the third step, since collectionTotalSupply
is zero, the artistSigned[_collectionID] == false
will not be checked, which means:
_collectionArtistAddress
can be changed to the real artist's address even after the bad collection admin already signed it.Manual
function setCollectionData(uint256 _collectionID, address _collectionArtistAddress, uint256 _maxCollectionPurchases, uint256 _collectionTotalSupply, uint _setFinalSupplyTimeAfterMint) public CollectionAdminRequired(_collectionID, this.setCollectionData.selector) { require(_collectionTotalSupply > 0); ...... }
Context
#0 - c4-pre-sort
2023-11-16T01:03:55Z
141345 marked the issue as sufficient quality report
#1 - c4-sponsor
2023-11-23T12:26:25Z
a2rocket (sponsor) disputed
#2 - a2rocket
2023-11-23T12:27:02Z
a collection is finalized once the collectionTotalSupply is set. There is no need for an admin to call that function when it does not have all the info required.
#3 - c4-judge
2023-12-06T16:54:55Z
alex-ppg marked the issue as duplicate of #741
#4 - c4-judge
2023-12-08T21:55:36Z
alex-ppg marked the issue as satisfactory