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: 36/243
Findings: 4
Award: $294.66
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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
The auctionDemo
contract allows to call the cancelBid
or the cancelAllBids
even after the execution of the claimAuction
if both calls are executed in a block with block.timestamp == minter.getAuctionEndTime(_tokenid)
. To illustrate it let's assume the following scenario:
block.timestamp == minter.getAuctionEndTime(_tokenid)
:
claimAuction
- the bidder can call this method since he is the winner and block.timestamp >= minter.getAuctionEndTime(_tokenid)
cancelAllBids
- the bidder can call this method since block.timestamp <= minter.getAuctionEndTime(_tokenid)
and the claimAution
doesn't change the status of the bids.claimAution
cancelAllBids
since all of his bids can be canceledSo basically the bidder gets the NFT for free and additionally steals 1 ether (in this scenario; it is possible to steal everything with some preconditions) from the auction contract.
The attack above is simplified and therefore is not optimal. To increase the probability of success the bidder can deploy some proxy contract that can:
claimAuction
cancelAllBids
in the receive
if all attack conditions are met:
block.timestamp == minter.getAuctionEndTime(_tokenid)
In this case, the bidder can send only one transaction, and therefore the probability of success is higher.
Since the timestamp of the next block on Ethereum is quite predictable the probability of this attack is relatively high. If the attack doesn't succeed the bidder will still get the NFT and receive all non-winning bids back so the user doesn't risk losing anything.
It is also worth mentioning that an attacker is not obligated to be a winner to perform this attack. If a bidder is not a winner he can still use the aforementioned proxy contract that will help him to call the cancelAllBids
if all conditions are met. But since a bidder can't control the claimAuction
call time the probability of success is much lower in this case.
A malicious user is able to steal ether from the auctionDemo
contract with a significant probability.
-
Manual Review
Consider replacing the condition block.timestamp >= minter.getAuctionEndTime(_tokenid)
with the strict one block.timestamp > minter.getAuctionEndTime(_tokenid)
in the auctionDemo.claimAuction
.
Timing
#0 - c4-pre-sort
2023-11-14T13:51:54Z
141345 marked the issue as duplicate of #1370
#1 - c4-pre-sort
2023-11-14T14:20:58Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-04T21:41:02Z
alex-ppg marked the issue as duplicate of #1323
#3 - c4-judge
2023-12-08T18:05:57Z
alex-ppg marked the issue as partial-50
🌟 Selected for report: The_Kakers
Also found by: 0xblackskull, BugzyVonBuggernaut, Draiakoo, Stryder, VAD37, alexfilippov314, mrudenko, rotcivegaf, xAriextz, xuwinnie, zach
275.7777 USDC - $275.78
The NextGenCore
contract allows a collection admin to change the collectionArtistAddress
even after a collection was signed by an artist. To illustrate it let's consider the following scenario:
createCollection
and registers the collection admin.setCollectionData
with his own _collectionArtistAddress
and the _collectionTotalSupply
equal to zero.artistSignature
using his own address.addRandomizer
and all the necessary setup functions in the NextGenMinterContract
including setting royalty payout addresses. It is important to note that it is a regular flow and it is not too optimistic to assume that admins might miss that the collectionTotalSupply
is zero.setCollectionData
again with the _collectionArtistAddress
equal to some famous artist's address and the _collectionTotalSupply
equal to a desired value. He is able to set a new artist's address since the collectionTotalSupply
is zero.A collection admin can set up a collection that looks as if it is signed by some famous artist when it is actually not. It is a clear violation of one of the main invariants:
context("Impersonate artist", () => { it("#createCollection", async function () { await contracts.hhCore.createCollection( "Test Collection", "Artist", "For testing", "www.test.com", "CCO", "https://ipfs.io/ipfs/hash/", "", ["desc"], ) }) it("#registerCollectionAdmin", async function () { await contracts.hhAdmin.registerCollectionAdmin( 5, signers.addr1.address, true, ) }) it("#setCollectionData", async function () { await contracts.hhCore.connect(signers.addr1).setCollectionData( 5, // _collectionID signers.addr2.address, // _collectionArtistAddress 2, // _maxCollectionPurchases 0, // _collectionTotalSupply 0, // _setFinalSupplyTimeAfterMint ) }) it("#artistSignature", async function() { await contracts.hhCore.connect(signers.addr2).artistSignature( 5, // _collectionID "Artist Signature", // _artistSignature ) }) it ("collectionArtistAddress == signers.addr2.address", async function() { expect(await contracts.hhCore.retrieveArtistAddress(5)).to.equal(signers.addr2.address); }) it("#setCollectionData again", async function () { await contracts.hhCore.connect(signers.addr1).setCollectionData( 5, // _collectionID signers.addr3.address, // _collectionArtistAddress 2, // _maxCollectionPurchases 10000, // _collectionTotalSupply 0, // _setFinalSupplyTimeAfterMint ) }) it ("collectionArtistAddress == signers.addr3.address", async function() { expect(await contracts.hhCore.retrieveArtistAddress(5)).to.equal(signers.addr3.address); }) })
Manual Review
Consider replacing condition collectionAdditionalData[_collectionID].collectionTotalSupply == 0
with wereDataAdded[_collectionID] == false
in the NextGenCore.setCollectionData
.
Invalid Validation
#0 - c4-pre-sort
2023-11-20T11:17:58Z
141345 marked the issue as duplicate of #303
#1 - c4-judge
2023-12-01T23:28:42Z
alex-ppg marked the issue as not a duplicate
#2 - c4-judge
2023-12-01T23:29:41Z
alex-ppg marked the issue as duplicate of #478
#3 - c4-judge
2023-12-08T21:55:28Z
alex-ppg marked the issue as satisfactory
🌟 Selected for report: HChang26
Also found by: 0x3b, 0xMAKEOUTHILL, 0xSwahili, 0xarno, ABA, DeFiHackLabs, Eigenvectors, Haipls, Kow, MrPotatoMagic, Neon2835, Nyx, Zac, alexfilippov314, ayden, c3phas, immeas, innertia, lsaudit, merlin, mojito_auditor, oakcobalt, ohm, oualidpro, peanuts, phoenixV110, sces60107, t0x1c, tnquanghuy0512, ubl4nk, volodya, xAriextz
5.4864 USDC - $5.49
A bidder can call the participateToAuction
after the execution of the claimAuction
if both transactions are in the block with block.timestamp == minter.getAuctionEndTime(_tokenid)
. To illustrate it let's consider the following scenario:
claimAuction
transactionparticipateToAuction
transactionblock.timestamp == minter.getAuctionEndTime(_tokenid)
and the participateToAuction
transaction is included after the claimAuction
transactionclaimAuction
will be executed successfully since block.timestamp >= minter.getAuctionEndTime(_tokenid)
. The winner will get the NFT, all other bids will be refunded.participateToAuction
will also be executed successfully since block.timestamp <= minter.getAuctionEndTime(_tokenid)
and there is no check that the auction was already claimedThe probability of such a situation is quite low but it shouldn't be possible at all.
A participant can accidentally lose his bid in the auction. It is a violation of one of the main invariants:
-
Manual Review
Consider replacing the condition block.timestamp >= minter.getAuctionEndTime(_tokenid)
with the strict one block.timestamp > minter.getAuctionEndTime(_tokenid)
in the auctionDemo.claimAuction
.
Timing
#0 - c4-pre-sort
2023-11-14T13:51:14Z
141345 marked the issue as duplicate of #1935
#1 - c4-pre-sort
2023-11-14T14:21:32Z
141345 marked the issue as duplicate of #962
#2 - c4-judge
2023-12-02T15:33:15Z
alex-ppg marked the issue as not a duplicate
#3 - c4-judge
2023-12-02T15:35:17Z
alex-ppg marked the issue as duplicate of #1926
#4 - c4-judge
2023-12-08T18:50:48Z
alex-ppg marked the issue as satisfactory
#5 - c4-judge
2023-12-08T18:50:54Z
alex-ppg marked the issue as partial-50
🌟 Selected for report: The_Kakers
Also found by: 00xSEV, 0x3b, Arabadzhiev, DeFiHackLabs, Fulum, Madalad, MrPotatoMagic, SpicyMeatball, Tadev, ZanyBonzy, ZdravkoHr, alexfilippov314, audityourcontracts, cheatc0d3, devival, dy, evmboi32, immeas, lsaudit, mrudenko, oakcobalt, oualidpro, pipidu83, r0ck3tz, rishabh, rotcivegaf, tpiliposian, xAriextz
13.3948 USDC - $13.39
[[1]] Function NextGenMinterContract.mint
contains a useless check:
// This condition always holds require(_numberOfTokens <= gencore.viewMaxAllowance(col), "Change no of tokens"); // If this condition holds require(gencore.retrieveTokensMintedPublicPerAddress(col, msg.sender) + _numberOfTokens <= gencore.viewMaxAllowance(col), "Max");
[[2]] Function NextGenMinterContract.burnToMint
contains a useless variable:
... uint256 collectionTokenMintIndex; collectionTokenMintIndex = gencore.viewTokensIndexMin(_mintCollectionID) + gencore.viewCirSupply(_mintCollectionID); ... // It has exactly the same value as the collectionTokenMintIndex uint256 mintIndex = gencore.viewTokensIndexMin(_mintCollectionID) + gencore.viewCirSupply(_mintCollectionID);
There are similar cases in the NextGenMinterContract.mintAndAuction
, NextGenMinterContract.burnOrSwapExternalToMint
.
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/MinterContract.sol#L267
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/MinterContract.sol#L281
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/MinterContract.sol#L362
[[3]] The statement collectionArtistPrimaryAddresses[_collectionID].status = false
in the NextGenMinterContract.proposePrimaryAddressesAndPercentages
and the NextGenMinterContract.proposeSecondaryAddressesAndPercentages
is useless since it is guaranteed to be false.
require (collectionArtistSecondaryAddresses[_collectionID].status == false, "Already approved"); ... collectionArtistSecondaryAddresses[_collectionID].status = false;
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/MinterContract.sol#L389 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/MinterContract.sol#L403
[[4]] Function fulfillRandomWords
in the NextGenRandomizerRNG
and NextGenRandomizerVRF
contains the construction:
bytes32(abi.encodePacked(numbers,requestToToken[id]))
This construction is equivalent to:
bytes32(numbers[0])
So it is not clear why this construction is used. I guess it was assumed to be:
bytes32(kecak256(abi.encodePacked(numbers,requestToToken[id])))
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/RandomizerRNG.sol#L49 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/RandomizerVRF.sol#L66
[[5]] The word "Watermelon" is never returned in the randomPool.randomWord
. The max value of the randomNum
is 99. In this case, getWord
will return wordsList[99 - 1]
that is "Velvet Apple".
[[6]] Function NextGenRandomizerNXT.calculateTokenHash
looks a bit overcomplicated. In terms of randomness it should be equivalent to:
bytes32 hash = keccak256(abi.encodePacked(_mintIndex, block.prevrandao, blockhash(block.number - 1), block.timestamp);
[[7]] There are multiple functions in the NextGenCore
(airDropTokens
, mint
, burnToMint
) with similar logic:
require(msg.sender == minterContract, "Caller is not the Minter Contract"); collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1; 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; } }
If the collectionCirculationSupply
is equal to collectionTotalSupply - 1
then the collectionCirculationSupply
will be increased but nothing will be minted. It is impossible now since this check is present in the minter contract. Still, it looks a bit fragile so it seems that it is better to replace if
statement with require
statement:
require(msg.sender == minterContract, "Caller is not the Minter Contract"); collectionAdditionalData[_collectionID].collectionCirculationSupply = collectionAdditionalData[_collectionID].collectionCirculationSupply + 1; require(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; }
https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/NextGenCore.sol#L181 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/NextGenCore.sol#L192 https://github.com/code-423n4/2023-10-nextgen/blob/8b518196629faa37eae39736837b24926fd3c07c/smart-contracts/NextGenCore.sol#L217
[[8]] There are a lot of places in the NextGenCore
contract where the value 10000000000
is used. Consider introducing a new constant instead.
#0 - 141345
2023-11-25T08:23:43Z
1078 alexfilippov314 l r nc 0 0 6
L 1 n L 2 n L 3 n L 4 d dup of https://github.com/code-423n4/2023-10-nextgen-findings/issues/852 L 5 d dup of https://github.com/code-423n4/2023-10-nextgen-findings/issues/508 L 6 n L 7 n L 8 n
#1 - c4-pre-sort
2023-11-25T08:27:48Z
141345 marked the issue as sufficient quality report
#2 - alex-ppg
2023-12-08T15:23:49Z
The Warden's QA report has been graded B based on a score of 20 combined with a manual review per the relevant QA guideline document located here.
The Warden's submission's score was assessed based on the following accepted findings:
#3 - c4-judge
2023-12-08T15:23:56Z
alex-ppg marked the issue as grade-b