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: 230/243
Findings: 1
Award: $0.00
🌟 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
Winner / highest bidder can drain the contract funds.
Highest bidder can steal the NFT with minimal loss
AuctionDemo
would be used to place bid on a particular NFT. whenever a bid is placed, the function participateToAuction
is called and bid is placed.
function participateToAuction(uint256 _tokenid) public payable { require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); auctionInfoStru memory newBid = auctionInfoStru(msg.sender, msg.value, true); auctionInfoData[_tokenid].push(newBid); }
This function will get the last highest bided value using the function returnHighestBid
and checks with current bid amount. The auction should be active (checks the end time).
Once the auction is over, either the winner or admin can call the function claimAuction
and claim the NFT. The amount will be sent to owner.
If the bidder wants to cancel the bid, they can call the function cancelBid
or cancelAllBids
to cancel and the bided amount is sent to them.
The issue here is the checks that is used to validate whether the bid is active or not ( within the auction end time)
Let see the codes.
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}(""); -------------------------------------->>> refund first time emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid); } else {} } } // cancel a single Bid function cancelBid(uint256 _tokenid, uint256 index) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true); auctionInfoData[_tokenid][index].status = false; (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}(""); -------------------->>> refund second time emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid); }
Both functions have a common check require(block.timestamp >= minter.getAuctionEndTime(_tokenid)
. Please note the equal
sign.
In the same block , the highest bidder can call the claimAuction
and cancelBid
. Note the block.timestamp
would be same.
First, highest bidder can call the function claimAuction
when block.timestamp == minter.getAuctionEndTime(_tokenid)
. Note, this function , refund the amount to the bidder if the bid amount is not highest bid (refer the else block). This is the place where the bidder will get the amount first time.
Also, the function cancelBid
will be called by this highest bidder.
when we see the check require(block.timestamp <= minter.getAuctionEndTime(_tokenid)
---->> note the equal sign.
So the, check will allow to cancel one time and refund the amount once again.
To steak the NFT with minimal loss, they will call the functions claimAuction
and cancelBid
sequentially in the same block where block.timestamp would be same when calling these functions.
Manual review.
We suggest to update the auctionInfoData[_tokenid][index].status = false;
inside the claim function as shown below.
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); auctionInfoData[_tokenid][index].status = false; ----------------------------->>>++++ } 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); auctionInfoData[_tokenid][index].status = false; ---------------------------------->> ++++ } else {} } }
ETH-Transfer
#0 - c4-pre-sort
2023-11-15T07:55:04Z
141345 marked the issue as duplicate of #1172
#1 - c4-judge
2023-12-06T21:28:08Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T18:07:21Z
alex-ppg marked the issue as partial-50
#3 - aktech297
2023-12-10T03:31:09Z
Hi @alex-ppg
Thanks for judging the issues very carefully.
We think that we have provided the adequate information similar to the parent issue (#1323)
First, highest bidder can call the function claimAuction when block.timestamp == minter.getAuctionEndTime(_tokenid). Note, this function , refund the amount to the bidder if the bid amount is not highest bid (refer the else block). This is the place where the bidder will get the amount first time. Also, the function cancelBid will be called by this highest bidder. when we see the check require(block.timestamp <= minter.getAuctionEndTime(_tokenid) ---->> note the equal sign. So the, check will allow to cancel one time and refund the amount once again. To steak the NFT with minimal loss, they will call the functions claimAuction and cancelBid sequentially in the same block where block.timestamp would be same when calling these functions.
We believe this issue also qualify for full credit.
Kindly check
Thanks!
#4 - alex-ppg
2023-12-10T12:51:59Z
Hey @aktech297, thanks for your feedback request! This submission was awarded partial credit of 50% because the recommended mitigation is incorrect and would still permit the vulnerability.
The root cause of the vulnerability is the equality check. For example, if we applied the mitigation you specified a user could still execute a re-entrancy attack and fully exploit the same amount. As your recommended mitigation does not solve the vulnerability and does not address the root cause, I consider this submission to be of partial credit.
#5 - aktech297
2023-12-10T16:12:00Z
Hi @alex-ppg
Thanks for your response.
The mitigation suggested here is sufficient and no need to worry about the reentrancy. we see that same mitigation is suggested in #1323.
as we suggested, setting the auctionInfoData[_tokenid][index].status = false;
in claimAuction
is sufficient to prevent this attack.
If they re-enter, following if condition will not allow the transaction and the funds will be safe.
Thanks!
#6 - alex-ppg
2023-12-12T11:48:56Z
Hey @aktech297, thanks for providing a follow-up response. The recommended mitigation sets the status
variable to false
after the external call, not before it. This means that the code would not conform to the CEI pattern and be vulnerable to a re-entrancy attack as the status
would still be true
when the call re-enters the cancelBid
function. As such, the partial credit will remain.