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: 131/243
Findings: 2
Award: $5.49
🌟 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
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L103-L121 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L123-L144
An auction winner can get the won NFT for free.
A bidder can get his refund twice, stealing funds from users.
Let's take a look at the claimAuction
function.
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 {} } }
The whole idea is that, a winner can claim his won NFT when block.timestamp >= minter.getAuctionEndTime(_tokenid)
- mind that it's >=, and he has the the highest bid, so the function has a for loop going through all bids that were present for this tokenID
.
Now let's look at the cancelBid
function:
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}(""); emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid); }
It checks if block.timestamp <= minter.getAuctionEndTime(_tokenid)
- mind that it's <=. Then finds the msg.sender's bid and refunds it, also setting his status
to false so he can't call it again and get another refund, but that actually does not hold.
As you can see cancelling a bid and claiming a bid can happen in the same block.timestamp
, because of the = sign present in both <= & >=.
Scenario on how a bidder can get refunded twice:
claimAuction
, and bundle his cancelBid
in the same block, but after claimAuction
. Getting the refund twice is possible because in claimAuction
whenever the winner calls it, it loops through all participants, and if they are not the highest bidder(the winner), they get their bid refunded, but the status
is not set to false as you can see in claimAuction
's code snippet above, so the bidder can then call cancelBid
, and get his refund one more time because this check will pass require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true);
.That way a bidder can steal non-refunded amount of ETH in the contract. Also this attack can be made even easier if an auction winner participates in the bid with a second account so he knows exactly when the claimAuction
will be called and again take advantage of other user's refunds.
If there is enough amount of ETH in the contract to cover the auction winner's bid, he can even get the NFT for free.
Manual audit
In claimAuction
make the following changes:
--require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
++require(block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
Timing
#0 - c4-pre-sort
2023-11-20T13:18:59Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-01T15:25:15Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-01T15:25:24Z
alex-ppg marked the issue as duplicate of #1788
#3 - c4-judge
2023-12-08T18:05:34Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: HChang26
Also found by: 0x3b, 0xMAKEOUTHILL, 0xSwahili, 0xarno, ABA, DeFiHackLabs, Eigenvectors, Haipls, Kow, MrPotatoMagic, Neon2835, Nyx, Zac, alexfilippov314, ayden, c3phas, immeas, innertia, lsaudit, merlin, mojito_auditor, oakcobalt, ohm, oualidpro, peanuts, phoenixV110, sces60107, t0x1c, tnquanghuy0512, ubl4nk, volodya, xAriextz
5.4864 USDC - $5.49
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L57-L60 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L104-L120
A bidder can bid for an auction the same time an auction is being finalized(claimed) and depending on which is executed first, it's possible for a bidder to lose his funds.
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); }
Participating in an auction is available when msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true
.
The important detail here is that: block.timestamp <= minter.getAuctionEndTime(_tokenid)
- keep in mind it's <=.
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 {} } }
Claiming an auction can be done when block.timestamp >= minter.getAuctionEndTime(_tokenid)
- again keep in mind it's <=, which means participating and claiming an auction can be done in the same block.timestamp which can be a problem depending on which transaction is executed first, because if the claimAuction
is executed before participateToAuction
and in the same block.timestamp, bidder's bid will be lost because there are no checks to see in participateToAuction
if an auction has been claimed, it only checks if they are in the same block.timestamp. So participateToAuction
WILL NOT revert and user will lose their bid, since there is no way to refund it.
Manual Audit
In claimAuction
make the following changes:
-- require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
++ require(block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
Timing
#0 - c4-pre-sort
2023-11-14T09:53:07Z
141345 marked the issue as duplicate of #1935
#1 - c4-pre-sort
2023-11-14T14:21:44Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-02T15:32:23Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-02T15:32:36Z
alex-ppg marked the issue as primary issue
#4 - c4-judge
2023-12-04T13:03:52Z
alex-ppg marked issue #175 as primary and marked this issue as a duplicate of 175
#5 - c4-judge
2023-12-08T18:47:21Z
alex-ppg marked the issue as satisfactory
#6 - c4-judge
2023-12-08T21:49:07Z
alex-ppg marked the issue as partial-50
#7 - c4-judge
2023-12-08T22:37:25Z
alex-ppg marked the issue as satisfactory
#8 - c4-judge
2023-12-08T22:37:36Z
alex-ppg marked the issue as partial-50