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: 82/243
Findings: 3
Award: $27.84
🌟 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
Every collection has an explicitly defined maxAllowance
. In fact, it will always need to be defined with some degree of thought, because failing to define it at all would give it the default value of 0
and disallow minting in most situations. Considering this, a user bypassing this limit and significantly exceeding the maxAllowance
presents a huge problem.
Since NextGenCore.mint
does not update the tokensMintedPerAddress
until after it mints a new token, an attacker would only need to recursively re-enter NextGenMinterContract.mint
in its onERC721Received
function to mint as many tokens as they want. When the minting completes, the balances will update — but with the checks having been done on each loop before minting, the updated tokensMintedPerAddress
would only matter if the attacker tried to mint
again in a subsequent transaction.
Impact
The purpose of the novel approaches to minting in this project is to ensure fair and controlled mints for all users. Despite the attacker still paying the defined price for every NFT they purchase, they have still completely undermined this purpose. And for an extremely successful project in a healthy market, an attacker could gain more than enough ether to cover the initial costs and still profit on secondary markets.
The only upper limits on an attacker's ability to mint are the transaction's gasLimit
and the collection's totalSupply
.
Proof of Concept
// Full test can be viewed here: https://gist.github.com/ethanbennett/a6b3c26ee723ed706f85d3b4c83da530 contract ReMinterancy is NextGenTestBase { function test_MintReentrancy() external { // deploy and fund malicious minter MaliciousMinter attacker = new MaliciousMinter(address(_minter)); vm.deal(address(attacker), 5 ether); // balance of attacker is currently 0 assertEq(_core.balanceOf(address(attacker)), 0); // the maximum allowance for the collection is 2, // so no user should ever gain more than 2 tokens from this function assertEq(_core.viewMaxAllowance(_collectionId), 2); // reenter `mint` recursively from MaliciousMinter.onERC721Received to exceed // maxAllowance before it updates vm.warp(1699284249); attacker.initialMint(_collectionId); // balance of attacker is now 100 assertEq(_core.balanceOf(address(attacker)), 100); } }
Recommended mitigation
Simply moving _mintProcessing
(L193, NextGenMinterContract.mint
) below the state updates would make this attack impossible:
function mint(uint256 mintIndex, address _mintingAddress , address _mintTo, string memory _tokenData, uint256 _saltfun_o, uint256 _collectionID, uint256 phase) external { require(msg.sender == minterContract, "Caller is not the Minter Contract"); collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1; 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); } }
Tools used Foundry
Reentrancy
#0 - c4-pre-sort
2023-11-20T11:44:26Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:03:33Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:23:03Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:23:12Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:06Z
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/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L105 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/AuctionDemo.sol#L125
This attack strings three smaller vulnerabilities together: reentrancy from _safeTransfer
, off-by-one errors in the auctionEndTime
checks in auctionDemo.cancelBid
and claimAuction
, and a failure to check that the low level refund call
s in AuctionDemo.claimAuction
succeed.
First, the attacker would need to pick a vulnerable collection: one with an auctionEndTime
that coincides with a new block.timestamp
. While block.timestamp
is no longer vulnerable to miner manipulation, valid block.timestamp
values can now be calculated well in advance. And with a new block reliably mined every twelve seconds, the attacker would likely not need to wait long for a vulnerable auction to occur.
Once an auction with a vulnerable auctionEndTime
begins, the attacker would need to place a winning bid from a contract with a malicious onERC721Received
function. With the high bid submitted, they would claimAuction
at precisely the block coinciding with the auctionEndTime
. This allows the attacker's malicious contract to call cancelBid
in its onERC721Received
callback.
While claimAuction
checks that an auction has ended by requiring that block.timestamp
is greater than or equal to the auctionEndDate
, cancelBid
checks that an auction has not ended by requiring that block.timestamp
is less than or equal to the auctionEndDate
. This means that, for exactly one second, the auction is both concluded and ongoing: a winning bid can be claimed and cancelled at the same time.
// auctionDemo.claimAuction L105 require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true); // auctionDemo.cancelBid L125 require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
Note that this attack is possible even without the reentrancy — this is just the more efficient path, and it is possibly cheaper as well (if the lower transaction costs adequately offset the cost of deploying the malicious contract, which is more likely to be the case if the attacker intends to exploit more than one auction).
Impact
While the auctioning account still gets paid, the last bidder in the array does not get their refund (unless the contract has the funds to cover the attacker's withdrawn bid). If there are multiple auctions happening at the same time, it is also possible that all refunds in the malicious transaction actually succeed, and the problem of the missing funds bubbles up in a later auction. In this scenario, the attack itself might not even be discovered until long after it occurs, if at all, and the attacker is free to continue exploiting vulnerable auctions as they come up.
Proof of Concept
// Full test can be viewed here: https://gist.github.com/ethanbennett/80d3214274b5572a1b1a11453a46e7eb contract AuctionReentrancy is NextGenTestBase { function test_AuctionReentrancy() external { uint256 tokenId; address auctioner = 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045; // mintAndAuction a new erc721 from NextGenMinter vm.warp(1699224249); tokenId = _core.viewTokensIndexMin(_collectionId) + _core.viewCirSupply(_collectionId); _minter.mintAndAuction(auctioner, "", 0, _collectionId, 1699226465); // set allowance so _auction can transfer nft to winner // note: this functionality is missing from NextGen contracts vm.prank(auctioner); _core.approve(address(_auction), tokenId); // deploy and fund MaliciousBidder (with arbitrary amount) MaliciousBidder attacker = new MaliciousBidder(address(_auction), tokenId); vm.deal(address(attacker), 1 ether); // store the Bidder's balance for testing uint256 etherBalanceBefore = address(attacker).balance; uint256 nftBalanceBefore = _core.balanceOf(address(attacker)); // create the high bid from MaliciousBidder attacker.bid(); // set the block.timestamp to exactly the auction's end time. // attacker would need to wait for an auction that ends at the same // time as a new block.timestamp vm.warp(1699226465); // claimAuction from MaliciousBidder and cancelBid inside onERC721Received attacker.claim(); // query balances for final testing uint256 etherBalanceAfter = address(attacker).balance; uint256 nftBalanceAfter = _core.balanceOf(address(attacker)); assertEq(etherBalanceBefore, etherBalanceAfter); assertGt(nftBalanceAfter, nftBalanceBefore); } } // MaliciousBidder.onERC721Received: function onERC721Received( address, address, uint256, bytes calldata ) external returns (bytes4) { _auction.cancelBid(_tokenId, 0); return IERC721Receiver.onERC721Received.selector; }
Recommended mitigation
claimAuction
to:require(block.timestamp > minter.getAuctionEndTime(_tokenid) && ( ... ) );
require(success)
for all low-level call
s. This alone would have made it much more difficult to exploit an auction successfully.Tools Used Foundry
Invalid Validation
#0 - c4-pre-sort
2023-11-20T11:43:43Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:41:54Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T17:53:12Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: MrPotatoMagic
Also found by: 3th, Fulum, JohnnyTime, K42, Kose, SAAJ, Toshii, catellatech, cats, clara, digitizeworx, dimulski, dy, glcanvas, hunter_w3b, ihtishamsudo, niki, peanuts
27.6948 USDC - $27.69
NextGen is a platform for launching generative art projects: collections of art with programmatically generated media, determined by a script written by an artist, and a randomizer that ensures dynamic and unique output for each piece.
Generative art has existed for decades, but has found a foothold in the NFT space in the last several years. Along with that popularity and value has come some big challenges: how can the creator of a popular generative art collection prevent bots from minting the whole supply? How can they make sure their work can be acquired at a reasonable price by their real community? How can they resist people using their work for short-term flips on secondary markets and losing track of the whole purpose of the project — the art — entirely?
These are the key problems that NextGen aims to solve, with a dynamic approach to minting processes and sales models that allows for flexibility and modularity in approaching these problems: artists can use allow lists, airdrops, "mint passes," and auctions to sell their work with either a fixed or a descending price, and configurable durations, total supplies, and allowances per account.
Most of the functionality is split between two contracts: NextGenCore
, the foundational layer of the system and the token contract itself, and NextGenMinterContract
, which acts as both the user interface for Core
and the accounting and payment system for artists and the NextGen team. Additionally, the majority of these functions are restricted to role-based admins, defined in NextGenAdmins
— an Ownable
but otherwise bespoke approach to user authorization.
Each token gets a randomized hash when it mints to guarantee unique output from the generative script. This hash gets its randomness in one of three ways, defined by the collection owner on a per-collection basis: Chainlink VRF, ARRNG, or a NextGen randomizer that does not require an external call. Each randomizer has its own NextGen-authored contract for interfacing with its source of randomness, one of which is designated in the collectionData
for each collection.
The last contract in scope is the auctionDemo
, where all the logic for operating an auction (after NextGenMinter.mintAndAuction
has created it in the first place) is implemented.
Test coverage is limited to minimal Hardhat unit tests. Natspec documentation is also sparse.
Observations and recommendations
Function complexity
Many of the key functions in these contracts are quite long, and contain several otherwise independent responsibilities — for example, NextGenMinterContract.mint
handles everything from checking the new token's validity all the way to regulating sales methods explicitly.
While refactoring such things might seem like a non-essential task with a tight timeline before launch, the opposite is actually true. In fact, most of the major vulnerabilities in this codebase can be attributed, in one way or another, to the monolithic quality of these functions. They make it easy to execute logic in the wrong order, they make it hard for developers to track everything that happens in the execution of the function, and, looking forward, they will increase the likelihood that vulnerabilities remain hidden in the code even after several rounds of review.
Extracting logical tangents into their own functions will also have the added benefit of making the system more easily extensible and more modular, both of which seem to be goals of the NextGen project. Implementing a function that handles all the logic related to periodic sales, for example, would instantly simplify Minter.mint
as well as the sales option itself, and it could facilitate new minting mechanisms as NextGen continues to experiment.
Architecture and storage
Likewise, the bounds of what should be defined and stored in the Minter
or the Core
are not clear. The result is similar, and best exemplified by the abundance of mappings and structs in both contracts.
Collection data is split between the two contracts ad hoc, so reviewing them means constantly guessing where some data is stored, often getting it wrong, and trying again. Worse, it obfuscates the relationship between each of these items in state, further complicating the process of defining and testing meaningful invariants in the code.
Consider extracting some functionality from both contracts: the storage, for instance, can be in a contract of its own and shared by Minter
and Core
. Many of these mappings can also be packed together in structs, and with so many instances throughout, it would make an appreciable difference in the cost of deploying the contracts (at the very least). It would also make sense to have a contract dedicated solely to the implementation of the sales options and price calculation. These are only examples — many similar refactoring opportunities exist throughout the codebase.
Admins
The NextGenAdmins
contract mostly succeeds at what it aims to do, but since there are no special requirements for the contracts' authorization mechanism, it is not clear why it actually needs to have been built by NextGen in the first place. Using OpenZeppelin's AccessControl
instead of Ownable
with NextGenAdmins
would come with all the functionality needed here, plus the added benefit of having already gone through plenty of careful review.
There is also a high degree of centralization risk, since the means exist both for the global admin and a particular function admin (discussed in a vulnerability) to withdraw all funds from NextGenMinterContract
without any safeguards.
The team has stated that they intend to use multisigs for all admin accounts, which is a great idea. But alone, it is insufficient to eliminate the centralization risk in this code, since it ultimately still requires trust in a central authority to follow these informal standards.
There are undeniably practical considerations that justify admin privileges throughout these contracts. But some high-risk functionality warrants more hard-coded security than the standard approach from the rest of the codebase, even if the intended global admin of NextGen is the most trustworthy person in the world. There will be forks, and the team will change, and the honeypot in the contract might explode in value. And even if every person involved until the end of time does turn out to be trustworthy, showing NextGen users that the platform is secure by improving just a few of the most high-risk areas will not have been a waste.
Incidentally, there is already a more careful, multi-party process applied to proposing the addresses and royalty percentages associated with each collection. Simply applying a similar mechanism, with a requirement for approval from multiple admins, where funds can be withdrawn (and where the admin contract can be updated) would be a huge improvement on its own.
Implicit assumptions and unexpected usage
In addition to the assumption that admins are trusted and will always use multisigs, there are other assumptions the team has mentioned that are not supported by the code. Another example of this is the expectation that the phases of a mint will not overlap, as communicated by the team in Discord but not enforced in the contracts.
While this would be important to be aware of in any context, it is an especially serious consideration for NextGen, because they are actually launching with two instances of these contracts. The second is a fork operating an entirely separate product — certificates in the form of NFTs from a project called UNIC. Unless UNIC and NextGen have the same team behind the curtain, this compounds issues like unofficial security requirements, assumptions about user behavior, and faith that admins will always know how to use the system properly: now there is no reason to assume every admin was involved in the development of these contracts, or that they inherit any trust or security from the NextGen team.
It is impossible for someone outside the NextGen team to do a full accounting of such assumptions made internally, but doing so is a worthwhile exercise for the team. Assumptions like these are blind spots that can often be exploited.
Miscellaneous recommendations
In addition to these larger items, there are a number of areas where the NextGen code could be augmented to boost security, improve user experience, and prevent unexpected errors and reversions.
Implementing these things will enable the next review to focus more on subtle, application-specific logic, and it will increase the odds that most major exploits are actually found and patched before launch.
Takeaways
Aside from the perennial value of tests and documentation, one interesting insight about block.timestamp
also emerged. While common knowledge says block.timestamp
is a less risky value now that it is not susceptible to miner manipulation, in situations where timing an attack is important, block.timestamp
is actually more dangerous to rely on now that it can be calculated with certainty in advance. In NextGen, this can be exploited to cancel a winning bid in an auction after claiming the auctioned NFT.
40 hours
#0 - c4-pre-sort
2023-11-27T14:37:28Z
141345 marked the issue as insufficient quality report
#1 - c4-judge
2023-12-07T16:50:26Z
alex-ppg marked the issue as grade-b