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: 2/243
Findings: 4
Award: $3,961.65
🌟 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.076 USDC - $0.08
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/NextGenCore.sol#L189-L200
Not following the recommended Checks-Effects-Interaction pattern in mint()
function allows
Contract addresses to re-enter using the onERC721Received()
hook
and mint more NFTs than they were allowed,
both during the allowlist & public sale phases.
Likelihood: High Impact : High
Ownership distribution & trust of the community are important metrics for an NFT collection. Allowing certain malicious actors to accumulate majority of the supply can lead to serious questions and distrust among the community.
Alice = Attacker
The Reentrancy arises from the fact that the _safemint()
external call is made
before updating the 2 important mappings tokensMintedAllowlistAddress
and tokensMintedPerAddress
.
tokensMintedAllowlistAddress
represents the number of NFTs minted by an address during allowlist phase
and
tokensMintedPerAddress
represents the number of NFTs minted by an address during public sale phase
The _safemint()
function calls the onERC721Received()
on the NFT receiving contract
to ensure it can safely handle NFTs before minting.
The Issue here is that, using this hook,
a malicious contract can callback into the NextGenMinterContract
to execute subsequent mints before the above mentioned mappings are updated.
This can be repeated until the total Supply of the collection is exhausted.
Lets consider, a new collection gets created for public sale
with following important parameters.
Collection ID = 1 TotalSupply = 1000 collectionMintCost = 0.1 Ether maxCollectionPurchases = 2 ( Maximum of 2 NFTs allowed per address during public)
Alice
is the attacker here and
wants to get as many NFTs for themselves,
even though the contract intends only a maximum of 2 NFTs per address during the public sale.
For the sake of this example, Lets only consider the important parameters required to illustrate this attack. Assume Alice has 100 ether
and initiates the attack like so Step 1
function initiateAttack() external { // Initiate call to the target contract console.log("Initiating attack on :", address(minterContract)); minterContract.mint {value : 0.1 ether} (1, // _collectionID 1, // _numberOfTokens 0, // _maxAllowance - allowlist sale limit - not relavant for this example '{"tdh": "100"}', // _tokenData address(this)); // _mintTo }
Step 2
function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) { _reentrancyCallback(); return this.onERC721Received.selector; }
Step 3
function _reentrancyCallback() internal { if (reenter) { // Execute attack _executeAttack(); } else if (!reenter) { // Already ran the attack once console.log("\n>>> Attack completed "); } }
Step 4
function _executeAttack() internal { (,,,uint256 totalSupply) = genCore.retrieveCollectionAdditionalData(1); if(genCore.viewCirSupply(1) < totalSupply) { ++ count; console.log("Reentry count : ", count ); minterContract.mint {value : 0.1 ether} (1, 1, 0, '{"tdh": "100"}', address(this)); } else { reenter = false; console.log(">>> All punks in my bag <<<"); console.log("Attacker NFT balance after Attack ", genCore.balanceOf(address(this)) ); } }
The above PoC shows Alice ends up with much more NFTs than intended by the NextGen system.
Note that,
the same attack can be executed during an allowlist sale.
which is even more critical.
As majority of the allowlisted members and the public will end up with no supply to mint.
This can lead to serious confidence issues among the NFT community regarding the legitimacy of the project.
Foundry, Manual Analysis.
Follow the check-effects-interaction pattern.
Update the state mappings tokensMintedAllowlistAddress
and tokensMintedPerAddress
before the external calls.
Reentrancy
#0 - c4-pre-sort
2023-11-20T06:34:09Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:03:23Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:24:43Z
alex-ppg marked the issue as partial-50
#3 - c4-judge
2023-12-08T16:24:49Z
alex-ppg marked the issue as satisfactory
#4 - c4-judge
2023-12-08T16:45:09Z
alex-ppg marked the issue as partial-25
#5 - c4-judge
2023-12-08T19:16:55Z
alex-ppg marked the issue as partial-50
🌟 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#L124-L130 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L134-L143
Any user can exploit the missing bid extension windows in the cancelBid()
& cancelAllBids()
functions
to win the auction with bids much lesser than perceived true market valuation.
Adam = Attacker
Assume an High demand NFT art is put on sale through mintAndAuction
Where _auctionEndTime
is set to 1699920000 (November 14, 2023 12:00:00 AM), and
_tokenId = 1000000420.
Lets say the perceived market valuation of the NFT is 5 ETH
Once the auction begins and assuming Adam is first bidder for sake of this example.
Adam could exploit the auction in the following steps In a single transaction.
Places an initial Low Bid 'A' of say 0.1 ETH for the _tokenId = 1000000420
.
Simultaneously places an outrageously High bid 'B' (say 50 ETH) on the same _tokenId
,
that is unlikely to be outbid.
Other interested parties dont see the value anymore in outbidding Adam as the price is very high. (This essentially creates a kind of DoS for other users from bidding)
Adam waits until the last moments of the auction deadline :
such that the block.timestamp = 1699919970
(November 13, 2023 11:59:30 PM)
which satisfies the check block.timestamp <= minter.getAuctionEndTime(_tokenid)
Cancels the High bid 'B' the cancelBid() function to (50 ETH) and receives the refund.
None of the other potential bidders are given enough time by the contract to counteract the cancelled bid.
Since there are no other bids present between his 2 bids A & B, due to the restriction. Adam observes the auction outcome to confirm that his low bid 'A' (0.1 ETH) wins the auction (unfairly).
This way Adam is able to use the lack of bid extension window to exploit the auction, making an hefty profit by selling the token in secondary market for a much higher price.
Resulting in
Manual Analysis
Here are some ways to mitigate this issue,
Other
#0 - c4-pre-sort
2023-11-15T07:12:48Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-02T15:12:15Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-02T15:14:35Z
alex-ppg marked the issue as duplicate of #1784
#3 - c4-judge
2023-12-07T11:50:57Z
alex-ppg marked the issue as duplicate of #1323
#4 - c4-judge
2023-12-08T17:16:52Z
alex-ppg marked the issue as partial-25
#5 - c4-judge
2023-12-08T17:27:51Z
alex-ppg marked the issue as satisfactory
#6 - c4-judge
2023-12-08T17:55:05Z
alex-ppg marked the issue as partial-25
🌟 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-L105 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L124-L125 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L134-L135
Missing auctionClaim[_tokenid] == false
checks in cancelBid()
& cancelAllBids()
functions and
Erroneous Condition checks with reference to _auctionEndTime
of a _tokenID
allows
an attacker to simultaneously call both claimAuction()
and cancelBid()
, at a particular block.timestamp.
Resulting in both
In Ethereum's Proof Of Stake (PoS) model, New blocks are added to the chain in exact 12 second intervals.
Validators know well in advance of which blocks they will be proposing to the chain.
Even if validators cannot manipulate the block.timestamp
like miners did in PoW Ethereum.
Any malicious user can easily monitor the Auction End Time
using the getAuctionEndTime()
function in the minter contract.
Then by looking at the require statements in the following functions,
It can be inferred that there will be times where
the block.timestamp
value will be exactly equal to the minter.getAuctionEndTime(_tokenid)
.
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");
function cancelAllBids(uint256 _tokenid) public { require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
Lets illustrate the attack flow.
Adam = Attacker Adam's balance = 101 ETHER auctioned _tokenId = 10000000000
contract AuctionAttack { AuctionDemo public auctionContract; NextGenMinterContract public minterContract; NextGenCore public genCore; constructor(address _auction, address _minter, address _genCore) { auctionContract = AuctionDemo(_auction); minterContract = NextGenMinterContract(_minter); genCore = NextGenCore(_genCore); } // To receive the NFT from genCore function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) { return this.onERC721Received.selector; } // attack is initiated if block.timestamp == Auction EndTime function pwnAuctionDemo(uint256 _tokenid) public { if(block.timestamp == minterContract.getAuctionEndTime(_tokenid)) { auctionContract.participateToAuction{value : 100 ether}(10000000000); // bidding 100 ether auctionContract.claimAuction(10000000000); auctionContract.cancelBid(10000000000 , 0); // assuming he's the first bidder console.log("Attacker ether balance after minting: ", address(this).balance); console.log("Attacker NFT balance after Attack ", genCore.balanceOf(address(this))); } } // To receive refund from auctioncontract fallback() external payable {} }
Adam monitors the auction end time (T) of all tokenIds put on auction. Let's call this time T.
(He observes that some NFTs have these 'T' values exactly in sync with block creation intervals)
such that, in the future.
A particular block.timestamp
= T
Adam Initiates the attack, When the required condition
if(block.timestamp == minterContract.getAuctionEndTime(_tokenid))
is satisfied.
by passing in the vulnerable `_tokenId` to the `pwnAuctionDemo()` function along with very high bid 'B' (say , 100 ETH) that will NOT be outbid.
As part of the same transaction, he also calls the claimAuction(10000000000)
to claim the NFT
since , block.timestamp >= minter.getAuctionEndTime(_tokenid)
is satisfied.
Still part of the same tx, and taking advantage of the <=
check and
the missing auctionClaim[_tokenid] == false
check
he then proceeds to cancel his bid 'B' (100 ETH) to receive the refund,
as require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended")
is satisfied.
This way Adam is able to exploit the Auction contract to steal the NFT without losing any ETH.
Manual analysis, Foundry
There are a few ways to solve this.
cancelBid
& cancelAllBids
functions both should add
the following check, and revert if the auction is already claimed.require(auctionClaim[_tokenid] == false, "Auction closed");
claimAuction()
function.
Remove the =
from the require statement so that the condition looks as shown below
block.timestamp > minter.getAuctionEndTime(_tokenid)
Invalid Validation
#0 - c4-pre-sort
2023-11-15T07:12:07Z
141345 marked the issue as duplicate of #1172
#1 - c4-pre-sort
2023-11-27T10:52:20Z
141345 marked the issue as not a duplicate
#2 - c4-pre-sort
2023-11-27T10:52:30Z
141345 marked the issue as duplicate of #962
#3 - c4-judge
2023-12-01T15:06:25Z
alex-ppg marked the issue as not a duplicate
#4 - c4-judge
2023-12-01T15:06:34Z
alex-ppg marked the issue as duplicate of #1788
#5 - c4-judge
2023-12-08T17:54:56Z
alex-ppg marked the issue as satisfactory
1114.2534 USDC - $1,114.25
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L240-L253
' Periodic sales' model cannot be used in a collection with Pre-existing supply
because of how timeOfLastMint
is calculated in mint()
function.
This prevents the system from flexibly combining different sales models in a single collection as intended by the business model.
https://seize-io.gitbook.io/nextgen/nextgen-smart-contracts/features#minting-process
The docs of the NextGen Protocol (linked above) states the following,
* A collection can have an arbitrary number of phases (starting and ending times), each with their own allowlist. * The sales models can be combined with phased allowlists to execute very complex drops.
Lets consider a common scenario followed in the NFT community, where Total Supply = 8500 collectionMintCost = 1 ETH 100 % of the total supply is allocate to allowlisted addresses, and any unminted NFTs go over to public sale phase.
Lets say 8000 NFTs are minted in allowlist phase.
The collection's admin wants to utilize ' Periodic sales' (sales model 3) for unminted tokens in a public sale phase.
In this case,
the lastMintDate[col]
would be assigned a value that is much higher due to gencore.viewCirSupply(col)
in the formula
lastMintDate[col] = collectionPhases[col].allowlistStartTime + (collectionPhases[col].timePeriod * (gencore.viewCirSupply(col) - 1));
This means timeOfLastMint
will be much greater than block.timestamp
and tDiff will be 0.
uint tDiff = (block.timestamp - timeOfLastMint) / collectionPhases[col].timePeriod;
This effectively reverts due to the following check,
require(tDiff>=1 && _numberOfTokens == 1, "1 mint/period");
preventing the NextGen system from flexibly combining different sales models in a single collection as intended by the business model.
Manual Analysis, Foundry.
Remodel the way to calculate ' lastMintDate[col] '
by tracking the number of NFTs minted through sales model 3
using a separate counter
without taking the collection's current ciculating supply into account.
Math
#0 - c4-pre-sort
2023-11-20T06:39:32Z
141345 marked the issue as sufficient quality report
#1 - c4-sponsor
2023-11-23T06:44:33Z
a2rocket (sponsor) confirmed
#2 - alex-ppg
2023-12-04T11:10:17Z
The Warden has marked a direct discrepancy between the NextGen system's documentation (expected behavior) as well as implementation (actual behavior) that would result in the minting phases of the system being incompatible between them if multiple are performed in sequence.
To note, this incompatibility between phases cannot be rectified by a re-configuration of the collection as its circulating supply would remain high meaning that collections as described by the exhibit would be permanently "bricked" from periodic sales. As such, I deem the high severity as apt for this finding.
While the submission is valid and has been accepted by the sponsor, I strongly advise the Warden to increase the quality of their future submissions by making use of bullet lists, better splitting of content, and correct usage of backticks for in-line code segments.
#3 - c4-judge
2023-12-04T11:10:25Z
alex-ppg marked the issue as satisfactory
#4 - c4-judge
2023-12-04T11:10:31Z
alex-ppg marked the issue as selected for report
#5 - c4-judge
2023-12-05T13:18:09Z
alex-ppg marked the issue as primary issue
#6 - c4-judge
2023-12-07T12:18:43Z
alex-ppg marked issue #380 as primary and marked this issue as a duplicate of 380
🌟 Selected for report: 0x3b
Also found by: AvantGard, lanrebayode77
2847.3221 USDC - $2,847.32
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/MinterContract.sol#L535-L536
' Periodic sales' model cannot be effectively used in a collection with Pre-existing supply, due to the unreasonably high prices reported by the currently employed price formula in 'getPrice()'.
This prevents the system from flexibly combining different sales models in a single collection as intended by the business model.
https://seize-io.gitbook.io/nextgen/nextgen-smart-contracts/features#minting-process
The docs of the NextGen Protocol (linked above) states the following,
* A collection can have an arbitrary number of phases (starting and ending times), each with their own allowlist. * The sales models can be combined with phased allowlists to execute very complex drops.
For Illustration, this means
A collection with a supply of 10,000 NFTs can be split into different phases, where each phase can have it own supply and sales model.
Lets consider a common scenario followed in the NFT community, where collectionMintCost = 1 ETH 100 % of the total supply is allocate to allowlisted addresses, and any unminted NFTs go over to public sale phase.
Here, the collection's admin wants to utilize ' Periodic sales' (sales model 3) for public sale phase.
where he wants the price to increase by 0.005 ETH ( 1.005 ETH, 1.01, 1.015 ETH and so on)
for every 30 second 'time period'
after the publicStartTime
.
if (collectionPhases[_collectionId].salesOption == 3) { if (collectionPhases[_collectionId].rate > 0) { return collectionPhases[_collectionId].collectionMintCost + ((collectionPhases[_collectionId].collectionMintCost / collectionPhases[_collectionId].rate) * gencore.viewCirSupply(_collectionId));
Lets say 9000 tokens are minted in the allowlist phase and 1000 tokens are left unminted. So it is time for public sale phase under' sales model 3'.
The potential buyer queries getPrice()
for this collection and gets an outrageous price of 451 ETH.
Its because the current formula calculates price using the circulating supply of the collection which is curently sitting at 9000.
This prevents the NextGen system from performing complex drops by effectively combine different sales models in a single collection as intended by the business model.
Manual Review, Foundry.
Remodel the way to calculate ' Periodic sales model ' prices
by tracking the number of NFTs minted through sales model 3
using a separate counter
without taking the collection's current ciculating supply into account.
Math
#0 - c4-pre-sort
2023-11-20T06:35:42Z
141345 marked the issue as insufficient quality report
#1 - c4-pre-sort
2023-11-20T06:35:48Z
141345 marked the issue as sufficient quality report
#2 - c4-pre-sort
2023-11-21T10:10:04Z
141345 marked the issue as duplicate of #89
#3 - c4-judge
2023-12-05T13:18:39Z
alex-ppg marked the issue as duplicate of #2012
#4 - c4-judge
2023-12-07T12:00:41Z
alex-ppg marked the issue as not a duplicate
#5 - c4-judge
2023-12-07T12:01:10Z
alex-ppg marked the issue as duplicate of #246
#6 - c4-judge
2023-12-08T22:32:59Z
alex-ppg marked the issue as satisfactory
#7 - c4-judge
2023-12-09T00:22:53Z
alex-ppg changed the severity to 2 (Med Risk)