Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Losing bids can be refunded twice #665

Closed
c4-submissions opened this issue Nov 9, 2023 · 3 comments
Closed

Losing bids can be refunded twice #665

c4-submissions opened this issue Nov 9, 2023 · 3 comments
Labels
3 (High Risk) Assets can be stolen/lost/compromised directly bug Something isn't working duplicate-1323 edited-by-warden satisfactory satisfies C4 submission criteria; eligible for awards

Comments

@c4-submissions
Copy link
Contributor

c4-submissions commented Nov 9, 2023

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L124-L130

Vulnerability details

Impact

    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"
        );

At block.timestamp == minter.getAuctionEndTime(_tokenid), it is possible to call both of these functions. This oversight creates an opportunity for a severe theft of funds.

Proof of Concept

  1. Adversary's contract makes few bids at the start of the auction
  2. Adversary's contract eventually gets outbid
  3. Adversary's contract makes a sufficiently high bid
  4. t = minter.getAuctionEndTime(_tokenid). Adversary's contract calls claimAuction. claimAuction refunds his first bid, which triggers Adversary's fallback, which calls cancelBid, which successfully refunds Adversary's bid again. The same happens for each of adversary's bids, except the winning one.

Example of Adversary's fallback:

    uint public index;
    bool cancel = true;

    fallback() external payable {
        if (index < 9) {
            if (cancel) {
                cancel = false;
                auctionDemo(msg.sender).cancelBid(tokenId, index++);
            } else {
                cancel = true;
            }
        } else {
            owner.call{value: address(this).balance}("");
        }
    }

Full Foundry PoC (works with and without require(success))

Depending on whether the mitigation for the M-01 finding from the bot race is implemented, there are two ways of how the Step 4 will play out:

require(success) is not implemented:

  1. Every losing bid is refunded to the bidders of the auction.
  2. Each refund of adversary's bids triggers the fallback, which calls cancelBid for the respective bid.
  3. The owner of the auctioned NFT will not get their payment as the contract will run out of ether. Depending on the size of Adversary's bids, some of the highest bids may not be refunded for the same reason.

require(success) is implemented:

require(success) after each call will make sure that every ether transfer has to be successful, or the whole transaction reverts. This means that the owner and all other bidders must get their ether back. If there is only one auction, and the Adversary tries to execute the original attack, the txn will revert, as at least one of the refunds will fail (due to insufficient balance of AuctionDemo).

Thus, the attack will not work unless there are other funds in the contract, but there usually will be, because the AuctionDemo contract may host multiple auctions at once.

To sum up:

if require(success) is used, the Adversary will need at least one other auction going in parallel for the attack to be successful;

if require(success) is not used, the attack won't have such requirement and will work out without other auctions.

Option to cancel the winning bid

The Adversary can also decide if he wants to pay for the NFT, or just get his winning bid back. Similarly to the previous strategy, he may add a call to his fallback, that would cancel his winning bid. This will essentially make the owner keep the NFT, and refund every bidder of the auction.

            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);

If the winning bid is cancelled, the condition will always be false, as the highest bid will have status == false

Draining of all other auctions

The contract also allows to make bids at t == minter.getAuctionEndTime(_tokenid). So the doubling strategy can be upgraded to draining all other auctions' funds.

Scenario:

  1. Hacker's contract calls claimAuction
  2. claimAuction refunds his bid
  3. Hacker's fallback calls participateToAuction
  4. claimAuction refunds his new bid
  5. Hacker's fallback calls cancelBid
  6. cancelBid calls Hacker's fallback

Steps 3-6 are repeated, each time with a bigger bid.

However, this particular scenario becomes impossible if the array length is cached (G-20 from the bot report).

Tools Used

Foundry

Recommended Mitigation Steps

    function claimAuction(uint256 _tokenid) public WinnerOrAdminRequired(_tokenid,this.claimAuction.selector){
-       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);

Assessed type

Timing

@c4-submissions c4-submissions added 3 (High Risk) Assets can be stolen/lost/compromised directly bug Something isn't working labels Nov 9, 2023
c4-submissions added a commit that referenced this issue Nov 9, 2023
@code4rena-admin code4rena-admin changed the title Adversary can claim the auctioned NFT and cancel his bid in the same transaction Adversary can claim the auctioned NFT and cancel his bid in the same transaction; losing bids can be refunded twice Nov 10, 2023
@code4rena-admin code4rena-admin changed the title Adversary can claim the auctioned NFT and cancel his bid in the same transaction; losing bids can be refunded twice Losing bids can be refunded twice Nov 11, 2023
@c4-pre-sort
Copy link

141345 marked the issue as duplicate of #962

@c4-judge
Copy link

c4-judge commented Dec 4, 2023

alex-ppg marked the issue as duplicate of #1323

@c4-judge
Copy link

c4-judge commented Dec 8, 2023

alex-ppg marked the issue as satisfactory

@c4-judge c4-judge added the satisfactory satisfies C4 submission criteria; eligible for awards label Dec 8, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
3 (High Risk) Assets can be stolen/lost/compromised directly bug Something isn't working duplicate-1323 edited-by-warden satisfactory satisfies C4 submission criteria; eligible for awards
Projects
None yet
Development

No branches or pull requests

4 participants