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: 103/243
Findings: 3
Award: $14.73
🌟 Selected for report: 1
🚀 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/main/smart-contracts/AuctionDemo.sol#L104 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L124
An auction winner can illicitly claim NFT assets without making a payment.
The vulnerability is rooted in a one-second overlap in the claimAuction()
function, permitting auction winners to claim NFTs without paying. By executing a specific attack, an attacker can exploit this scenario. They first create a smart contract to participate in the auction, which necessitates implementing onERC721Received()
to receive NFTs. Inside this function, the attacker incorporates custom logic to call cancelBid()
. When claimAuction()
is triggered within the one-second overlap, the attacker will receive the NFT and receive a full refund.
Here is how it works:
participateToAuction()
.onERC721Received()
function to execute a call to cancelBid()
.claimAuction()
is accessible during the one-second overlap. In this process, the attacker acquires the NFT without making any payment.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 {} } }
As you can see, all checks will be bypassed in cancelBid()
.
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); }
auctionDemo.sol
is designed to be able to have multiple auctions going at the once, where the low-level call revert due to insufficient fund will not be an issue.
claimAuction()
and cancelBid()
can also simply be batched call together to achieve the same attack.
Note: Bidders can also use similar attack path if winner calls claimAuction()
in the overlap.
Manual Review
function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){ - require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false + 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) { + auctionInfoData[_tokenid][i].status == false; 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) { + auctionInfoData[_tokenid][i].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 {} } }
Context
#0 - c4-pre-sort
2023-11-15T10:39:07Z
141345 marked the issue as duplicate of #1172
#1 - c4-judge
2023-12-06T21:27:59Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T18:23:10Z
alex-ppg marked the issue as satisfactory
🌟 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#L104
A malicious auction winner can potentially freeze all the funds of other bidders.
The claimAuction()
function is designed to be callable only by the auction winner and administrators. It serves to transfer the NFT to the auction winner, pay the original owner, and provide refunds to bidders who did not win. However, a malicious actor can exploit this function to deliberately block the NFT transfer, resulting in the freezing of funds belonging to other bidders.
Here is how the attack works:
onERC721Received
, which is necessary for smart contracts to receive NFTs.participateToAuction()
.claimAuction()
.claimAuction()
, but it always reverts as the smart contract cannot receive NFT.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 {} } }
Manual Review
Consider creating a different function to handle refunds for individual bidders.
Context
#0 - c4-pre-sort
2023-11-15T10:36:59Z
141345 marked the issue as duplicate of #843
#1 - c4-pre-sort
2023-11-16T13:35:19Z
141345 marked the issue as duplicate of #486
#2 - c4-judge
2023-12-05T22:18:30Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-05T22:18:52Z
alex-ppg marked the issue as duplicate of #739
#4 - c4-judge
2023-12-08T22:21:57Z
alex-ppg marked the issue as partial-50
#5 - Henrychang26
2023-12-09T07:48:13Z
Thanks for judging @alex-ppg,
I believe this submission deserves full credit.
The report identifies the root cause of issue of not having onERC721Received()
and its impact of potentially freezing other bidders funds.
#6 - alex-ppg
2023-12-09T12:57:37Z
Hey @Henrychang26, thanks for your response! While the submission details the root cause, it focuses on the absence of its implementation rather than it being omitted / deliberately reverting.
As such, it was awarded partial credit of 50%. This submission does not detail that the winner f.e. is incentivized to revert to blackmail, ask ransom, etc. nor does it detail the invariants that are broken. In reality, I would award this a 75% partial credit but such an option does not yet exist rendering my initial 50% credit rating to stand.
#7 - Henrychang26
2023-12-09T22:00:37Z
Thanks @alex-ppg,
I still believe this submission deserves full credit. Yes, I agree the report could have gone more in depth in explaining the impact. However, the impact is quite obvious in my opinion and does not need much supporting arguments.
I would like to point out the report does mention about claimAuction()
reverting:
Hey @Henrychang26, thanks for your response! While the submission details the root cause, it focuses on the absence of its implementation rather than it being omitted / deliberately reverting.
See Here:
4. An admin steps in to execute claimAuction(), but it always reverts as the smart contract cannot receive NFT.
This report has everything you are looking for to receive full credit.
Thanks again for re-consideration!
#8 - alex-ppg
2023-12-10T14:37:30Z
Thanks for flagging @Henrychang26, I will revisit it after the PJQA process for a potential reward adjustment. To note, this is merely a guarantee that I will re-review and compare it with other similarly penalized submissions.
🌟 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
14.2647 USDC - $14.26
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L57 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L104
Bidder funds may become irretrievable if the participateToAuction()
function is executed after claimAuction()
during a 1-second overlap.
The protocol allows bidders to use the participateToAuction()
function up to the auction's end time.
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); }
However, the issue arises when an auction winner immediately calls claimAuction()
right after the auction concludes, creating a 1-second window during which both claimAuction()
and participateToAuction()
can be executed.
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 issue arises when claimAuction()
is executed before participateToAuction()
within this 1-second overlap. In such a scenario, the bidder's funds will become trapped in AuctionDemo.sol
without any mechanism to facilitate refunds. Both cancelBid()
and cancelAllBids()
functions will revert after the auction's conclusion, making it impossible for bidders to recover their funds.
Manual Review
function participateToAuction(uint256 _tokenid) public payable { - require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true); + 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); }
Context
#0 - c4-pre-sort
2023-11-15T10:37:13Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-02T15:33:36Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-02T15:35:51Z
alex-ppg marked the issue as duplicate of #1926
#3 - c4-judge
2023-12-04T13:03:56Z
alex-ppg marked the issue as selected for report
#4 - alex-ppg
2023-12-04T13:09:13Z
The Warden has clearly specified what the vulnerability is, has provided a recommended course of action that aligns with best practices, and has specified all aspects of the contract that would fail for the user if they tried to reclaim their lost funds.
The likelihood of this exhibit manifesting in practice is relatively low (requires a block.timestamp
that exactly matches the auction). In the post-merge PoS Ethereum that the project intends to deploy, blocks are guaranteed to be multiples of 12
and can only be manipulated as multiples of it.
The impact is high as the funds of the user are irrecoverably lost even with administrative privileges as no rescue mechanism exists, rendering this exhibit a medium severity issue.
#5 - c4-judge
2023-12-04T13:09:30Z
alex-ppg marked the issue as satisfactory
#7 - mcgrathcoutinho
2023-12-09T13:35:30Z
Hi @ZdravkoHr, I disagree that these both have the same root cause.
The issue here is that "<=" is used instead of "<" in participateToAuction()
while #1323 mentions the same but in claimAuction()
. Solving #1323 does not solve this issue here since funds can still be locked. Additionally, for #1323 there are two mitigations, either 1) using "<" instead of "<=" in claimAuction()
or 2) setting auctionInfoData to false for each bid in the for loop of claimAuction
. Since we do not know which mitigation the sponsor may apply, it is best to keep this separate.
#8 - 0xbtk
2023-12-09T14:41:55Z
Hey @alex-ppg, I think this should be partial of #1323. The warden identified the root cause but didn't cover the full impact. Otherwise you should consider splitting re-entrancies and back-running attacks into separate issues.
Regarding the warden's above comment, fixing #1323 will fix this as there aren't two mitigations; they're the same and the sponsor needs to apply them both to fix #1323.
#9 - alex-ppg
2023-12-09T16:21:19Z
Hey @ZdravkoHr, @mcgrathcoutinho, and @0xbtk; thanks a lot for your feedback! I will attempt to address everyone's response in this reply.
The project contained multiple vulnerabilities that can be argued to have the same root cause. The root cause of this submission is that it is possible to participate in an auction beyond its end. As @mcgrathcoutinho specified earlier, this is a distinct case as #1323 does not rely on the participateInAuction
function being executable at a specific timestamp.
If we know both issues, of course, we can say that the expected remediation is to simply code claimAuction
in a way that does not permit the overlap for both simultaneously. However, each submission is meant to be treated in a black box and as such, adjusting bid cancellations to not be possible at the last second is an entirely acceptable remediation of #1323 that would not solve this submission.
Re-entrancies are indeed split into separate attacks. You can observe this by evaluating #1597 and #1517 which use the same re-entrancy origin differently. In general, a re-entrancy attack's root cause is the absence of the CEI pattern; not the re-entrancy itself which is a trait of the EVM. If there are instances where you believe re-entrancy attacks have been merged incorrectly, please flag them and I will review them.
Back-running attacks and re-entrancy attacks are the same when it comes to this particular exhibit. A back-running attack is the prime vulnerability / root cause because the time overlap permits a cancellation to occur at the same timestamp as a claim. If we fix re-entrancy, we will not fix the back-running attack but if we fix the back-running attack the re-entrancy will no longer be possible.
As such, re-entrancy is simply a tool via which time inconsistency is exploited. Whether you choose to exploit it via a re-entrancy or a back-running attack; they are equivalent. If you mention re-entrancy guards as the correct fix, however, your submission will be penalized.
Please let me know if you have any further concerns.