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: 164/243
Findings: 3
Award: $0.62
🌟 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#L189-L200
A collection will be either in the allowlist phase or public phase. In both these phases, the creator of the collection can specify the max amount of NFT's a participant can mint by using the _maxCollectionPurchases
in NextGenCore.setSollectionData()
:
NextGenCore.sol#L151
function setCollectionData(..) { collectionAdditionalData[_collectionID].maxCollectionPurchases = _maxCollectionPurchases; }
However, due to a reentrancy, a person can mint more than the allowance. I have written a PoC for the public mint and for the allowlist mint, which are provided of the end of each section.
The problem lays in the usage of _safeMint
before updating the variable tokensMintedPerAddress
first. _safeMint
gets called in _mintProcessing
:
NextGenCore.sol#L231
function _mintProcessing(..){ //.. ommitted code _safeMint(_recipient, _mintIndex); }
During the public mint phase, _mintProcessing
gets called before updating the tokensMintedPerAddress
variable:
NextGenCore.sol#L231
function mint(..) //.. ommitted code _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); //.. ommitted code } else { tokensMintedPerAddress[_collectionID][_mintingAddress] = tokensMintedPerAddress[_collectionID][_mintingAddress] + 1; } } }
This results in the ERC721 token being minted before tokensMintedPerAddress
gets updated. This means that a malicious user can mint with a contract that implements the onERC721Received()
function. When the contract receives the ERC721 token, the onERC721Received
function will be triggered, which will call MinterContract.mint()
again and again.
This check, which is meant to prevent people from minting more than allowed, will always pass until the tokensMintedPerAddress
is updated:
MinterContract.sol#L224
require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
Here is a gist of the Public Mint PoC, it is written using Foundry. Instructions are in the gist. Click here
The same logic explained above applies to the Allowlist period. _safeMint
, which gets called in
NextGenCore._mintProcessing
, is called before the variable tokensMintedAllowlistAddress
is updated:
function mint(..){ _mintProcessing(mintIndex, _mintTo, _tokenData, _collectionID, _saltfun_o); if (phase == 1) { tokensMintedAllowlistAddress[_collectionID][_mintingAddress] = tokensMintedAllowlistAddress[_collectionID][_mintingAddress] + 1; } }
The same logic as during the public mint applies here as well. A person can loop their mint because this check will not fail until the tokensMintedAllowlistAddress
is updated:
MinterContract.sol#L213 MinterContract.sol#L217
require(_maxAllowance >= gencore.retrieveTokensMintedALPerAddress(col, _delegator) + _numberOfTokens, "AL limit");
Here is a gist of the Allowlist PoC, it is written using Foundry. Instructions are in the gist. Click here
The max amount minted per address is one of the most important settings for NFT collections. You want your loyal collector to be able to collect your NFT pieces by allowlisting them a set amount. Having a single person or a few persons exploiting this to hoard more supply than the rest is not what you want. With the current setup of the minting process, everyone is able to mint as much as he wants, breaking the core functionality of this project.
Foundry, manual review
Follow the CEI-pattern, update the state variables before calling _safeMint()
.
Reentrancy
#0 - c4-pre-sort
2023-11-20T05:46:24Z
141345 marked the issue as duplicate of #51
#1 - c4-pre-sort
2023-11-26T14:02:56Z
141345 marked the issue as duplicate of #1742
#2 - c4-judge
2023-12-08T16:31:21Z
alex-ppg marked the issue as satisfactory
#3 - c4-judge
2023-12-08T16:33:11Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-08T19:17:13Z
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/smart-contracts/AuctionDemo.sol#L58 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L122-L143 https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L102-L120
This report will show two scenarios that will happen due to wrong validation of block.timestamp
+ not correctly validating the return value. This two issues combined will result in a bidder stealing the funds of others.
In Auctiondemo.sol
, a person can bid an amount on an auctioned NFT using participateToAuction
, as long as you fulfil the following requirements:
require(msg.value > returnHighestBid(_tokenid) && block.timestamp <= minter.getAuctionEndTime(_tokenid) && minter.getAuctionStatus(_tokenid) == true);
The person placing a bid has to bid higher than the current highest bid AND the block.timestamp
has to be less or equal to the ending time of the auction AND the auction status of the tokenId has to be true.
A person can cancel their bid using either cancelBid
or cancelAllBids
, both requiring:
require(block.timestamp <= minter.getAuctionEndTime(_tokenid), "Auction ended");
The block.timestamp
has to be less or equal to the ending time of the auction, just like the participateToAuction
function.
The winner of an auction(or the admin) can call claimAuction
to claim the auctioned NFT. In this function the losing bids will all be refunded. This requires:
require(block.timestamp >= minter.getAuctionEndTime(_tokenid) && auctionClaim[_tokenid] == false && minter.getAuctionStatus(_tokenid) == true);
block.timestamp
has to be greater or equal to the end time of the auction.
The problem, here lays in the fact that a person can execute all these above-mentioned functions when block.timestamp == auctionEndtime
.
In this scenario, the winner of an auction takes the NFT + gets a full refund of the amount he should have paid, breaking the main invariant that should never be broken according to the Sponsor:
- The highest bidder will receive the token after an auction finishes, the owner of the token will receive the funds and all other participants will get refunded.
Let:
block.timestamp
is 2000Malicious user calls claimAuction
to claim the auction. In this function, address(1) and address(2) will be successfully refunded, however, the problem lays here:
IERC721(gencore).safeTransferFrom(ownerOfToken, highestBidder, _tokenid); (bool success, ) = payable(owner()).call{value: highestBid}("");
A malicious user can just integrate the onERC721Received
function in his exploit contract wherein a call will be made to cancelBid
. This call to cancelBid
will not fail because the check that checks if an auction has ended will pass because current block.timestamp == 2000 == auctionEndTime
.
This results in the winner being able to receive the ERC721 token + cancelling his bid and getting a successful refund. In the next line, the wallet that was temporary holding the auctioned NFT, is supposed to get paid:
(bool success, ) = payable(owner()).call{value: highestBid}("");
However, this will silently fail because there are no funds left in Auctiondemo.sol
to pay him out.
Here is a PoC I wrote, instructions are provided in the gist. Click here
In this scenario, the bidder is able to steal the funds of other bidders.
Let:
Address(2) wins the auction and calls claimAuction
to claim the ERC721 token.
However, the exploit contract implements a fallback
function that calls cancelBid
every time it receives ETH, which will happen in the loop during the claimAuction
function:
(bool success, ) = payable(auctionInfoData[_tokenid][i].bidder).call{value: auctionInfoData[_tokenid][i].bid}(""); emit Refund(auctionInfoData[_tokenid][i].bidder, _tokenid, success, highestBid);
Since the malicious user has placed the bids before the bids of address(1)
and address(2)
, he will get refunded first because the loop goes through the auctionInfoData
starting from index 0
. This means that every time the exploit contract receives a refund, they can double-dip by cancelling their own bid using cancelBid
. This call will succeed because block.timestamp == 2000 == auctionEndTime
and every failed call will silently revert because the return value of the low level .call()
is not being checked.
This scenario also breaks the main invariant:
- The highest bidder will receive the token after an auction finishes, the owner of the token will receive the funds and all other participants will get refunded.
I wrote a PoC for this scenario as well - instructions are provided in the gist. Click here
Foundry, Manual Review
Usage <
and >
instead of =<
and >=
will solve the current scenarios above, however, it is also recommended to handle the return value of calls
.
Other
#0 - c4-pre-sort
2023-11-20T05:44:12Z
141345 marked the issue as duplicate of #962
#1 - c4-judge
2023-12-04T21:41:07Z
alex-ppg marked the issue as duplicate of #1323
#2 - c4-judge
2023-12-08T18:05:08Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: The_Kakers
Also found by: 00decree, 00xSEV, 0x180db, 0x3b, 0xJuda, 0x_6a70, 0xarno, 0xpiken, Arabadzhiev, Bauchibred, BugsFinder0x, BugzyVonBuggernaut, ChrisTina, DeFiHackLabs, Delvir0, HChang26, Haipls, Jiamin, Juntao, KupiaSec, Madalad, Neon2835, Nyx, Ocean_Sky, SpicyMeatball, Talfao, Taylor_Webb, Timenov, Tricko, ZdravkoHr, _eperezok, alexxander, amaechieth, bdmcbri, bronze_pickaxe, circlelooper, crunch, cu5t0mpeo, dimulski, fibonacci, funkornaut, immeas, ke1caM, lsaudit, nuthan2x, r0ck3tz, rotcivegaf, spark, tnquanghuy0512, twcctop, xeros
0.4703 USDC - $0.47
https://github.com/code-423n4/2023-10-nextgen/blob/main/smart-contracts/AuctionDemo.sol#L112
The Sponsor stated in the README.md of this contest:
Consider if any functionality does not work with a SAFE (formerly Gnosis) wallet.
However, any function that ends up using .safeTransferFrom
to send an ERC721
token will not always work with Safe wallets because not all Safe wallets will have a fallbackManager
implemented due to various (security) reasons. The fallbackManager
ensures that a Safe wallet is able to receive ERC721
tokens by implementing onERC721Received.
Since in this project, safeTransferFrom
is used to transfer the ERC721
, this transfer will revert with the following error code:
file: smart-contracts/ERC721.sol require(_isApprovedOrOwner(_msgSender(), tokenId), "ERC721: caller is not token owner or approved");
What for impact does this have? Consider the following:
AuctionDemo.claimAuction
to distribute the NFT.ERC721TokenReceiver
.cancelBid
because the auction has ended.Manual review.
For the exact scenario described above, split the refund mechanism into a different function that is not at mercy of the ERC721.safeTransferFrom()
function succeeding, which people can call after the auction has ended.
Other
#0 - c4-pre-sort
2023-11-20T05:45:47Z
141345 marked the issue as duplicate of #486
#1 - c4-judge
2023-12-01T22:32:06Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-01T22:32:36Z
alex-ppg marked the issue as duplicate of #1759
#3 - c4-judge
2023-12-08T22:13:10Z
alex-ppg marked the issue as partial-50
#4 - c4-judge
2023-12-09T00:23:13Z
alex-ppg changed the severity to 2 (Med Risk)