Platform: Code4rena
Start Date: 20/12/2022
Pot Size: $24,500 USDC
Total HM: 7
Participants: 4
Period: 2 days
Judge: GalloDaSballo
Total Solo HM: 4
Id: 198
League: ETH
Rank: 3/4
Findings: 2
Award: $0.00
🌟 Selected for report: 2
🚀 Solo Findings: 0
🌟 Selected for report: Dravee
Also found by: Lambda, hansfriese
Data not available
https://github.com/code-423n4/2022-12-forgotten-runiverse/blob/dcad1802bf258bf294900a08a03ca0d26d2304f4/contracts/RuniverseLand.sol#L77 https://github.com/code-423n4/2022-12-forgotten-runiverse/blob/dcad1802bf258bf294900a08a03ca0d26d2304f4/contracts/RuniverseLand.sol#L101
RuniverseLand.sol#mint()
can be bricked.
The mint()
function uses numMinted
to generate the tokenId
:
File: RuniverseLand.sol 72: function mint(address recipient, PlotSize size) 73: public 74: override 75: returns (uint256) 76: { 77: uint256 tokenId = numMinted; 78: mintTokenId(recipient, tokenId, size); 79: return tokenId; 80: }
This numMinted
value corresponds to the totalSupply()
:
File: RuniverseLand.sol 145: function totalSupply() public view returns (uint256) { 146: return numMinted; 147: }
However, the mintTokenId()
function can be called with any tokenId
:
File: RuniverseLand.sol 088: function mintTokenId( 089: address recipient, 090: uint256 tokenId, 091: PlotSize size 092: ) public override nonReentrant { 093: require(numMinted < MAX_SUPPLY, "All land has been minted"); 094: require( 095: _msgSender() == primaryMinter || _msgSender() == secondaryMinter, 096: "Not a minter" 097: ); 098: numMinted += 1; 099: emit LandMinted(recipient, tokenId, size); 100: 101: _mint(recipient, tokenId); 102: }
Imagine the following scenario:
numMinted == 0
primaryMinter
calls mintTokenId()
with tokenId == 1
numMinted == 1
secondaryMinter
calls mint()
tokenId == numMinted
_mint()
gets called with tokenId == 1
which already exists, so it failsGiven that RuniverseLand.sol#mint()
isn't called in RuniverseLandMinter
, I feel like it should simply be deleted.
primaryMinter
or secondaryMinter
can simply call mintTokenId()
directly
#0 - GalloDaSballo
2022-12-23T19:52:23Z
Valid concern based on external requirements + the fact that arbitrary data can be inputted
#1 - GalloDaSballo
2022-12-23T20:28:31Z
Slightly better POC (scenario)
#2 - c4-judge
2022-12-23T20:28:35Z
GalloDaSballo marked the issue as primary issue
#3 - c4-sponsor
2023-01-04T02:06:10Z
msclecram marked the issue as sponsor confirmed
#4 - GalloDaSballo
2023-01-05T10:49:52Z
The warden has shown an inconsistency in using mint
and mintTokenId
which can cause the bricking of the minting functionality.
Because this is an undesirable scenario which can happen via ordinary operations, I agree with Medium Severity
#5 - c4-judge
2023-01-06T14:28:12Z
GalloDaSballo marked the issue as selected for report
#6 - msclecram
2023-01-11T04:34:55Z
We updated the code with the next changes: -We removed mint method from interface and contract
https://github.com/bisonic-official/plot-contract/commit/ea8abd7faffde4218232e22ba5d8402e37d96878
🌟 Selected for report: Dravee
Also found by: hansfriese
Data not available
https://github.com/code-423n4/2022-12-forgotten-runiverse/blob/dcad1802bf258bf294900a08a03ca0d26d2304f4/contracts/ERC721Vestable.sol#L99-L101 https://github.com/code-423n4/2022-12-forgotten-runiverse/blob/dcad1802bf258bf294900a08a03ca0d26d2304f4/contracts/ERC721Vestable.sol#L44 https://github.com/code-423n4/2022-12-forgotten-runiverse/blob/dcad1802bf258bf294900a08a03ca0d26d2304f4/contracts/ERC721Vestable.sol#L65
Past similar finding with the same severity: https://github.com/code-423n4/2022-05-runes-findings/issues/30
While centralization risk is acknowledged by the team & the C4udit tool: this may lead to loss of functionality (grief).
There is no requirement for the start time to be before the end time:
File: ERC721Vestable.sol 099: function _setVestingStart(uint48 _newVestingStart) internal virtual { 100: vestingStart = _newVestingStart; //@audit-issue can be set after vesting end 101: } ... 106: function _setVestingEnd(uint48 _newVestingEnd) internal virtual { 107: require( 108: _newVestingEnd > vestingStart, 109: "End must be greater than start" 110: ); 111: vestingEnd = _newVestingEnd; 112: }
Changing the start time in such a way (by error) can break the logic of transfer during the vesting period:
ERC721Vestable.sol#_beforeTokenTransfer()
:File: ERC721Vestable.sol 31: function _beforeTokenTransfer( 32: address from, 33: address to, 34: uint256 tokenId 35: ) internal virtual override { 36: super._beforeTokenTransfer(from, to, tokenId); 37: uint256 globalId = getGlobalId(tokenId); 38: if ( 39: vestingEnabled && 40: from != address(0) && // minting 41: globalId <= lastVestingGlobalId && 42: block.timestamp < vestingEnd 43: ) { 44: uint256 vestingDuration = vestingEnd - vestingStart; //@audit Griefing if vestingStart > vestingEnd (possible) 45: uint256 chunk = vestingDuration / lastVestingGlobalId; 46: require( 47: block.timestamp >= (chunk * globalId) + vestingStart, 48: "Not vested" 49: ); 50: } 51: 52: }
While less severe, it can also break the following view function:
ERC721Vestable.sol#vestsAt()
:65: uint256 vestingDuration = vestingEnd - vestingStart;
I believe that vestingStart
and vestingEnd
should be immutable/constants. But in case the sponsor still wants them to be editable: consider adding the following check:
File: ERC721Vestable.sol 099: function _setVestingStart(uint48 _newVestingStart) internal virtual { + 100: require(_newVestingStart < vestingEnd, "End must be greater than start"); 100: vestingStart = _newVestingStart; //@audit-issue can be set after vesting end 101: }
The process to edit the vesting period would then be to first edit vestingEnd
if the new vestingStart
is going to be bigger that it.
#0 - GalloDaSballo
2022-12-23T20:05:32Z
Worth flagging, unclear on severity and if the sponsor intended for vesting to be changeable or not
#1 - GalloDaSballo
2022-12-23T20:06:40Z
With the info we have we know this undefined state can be achieved, so probably valid, but acceptable nofix by sponsor
#2 - c4-sponsor
2023-01-04T02:05:38Z
msclecram marked the issue as sponsor confirmed
#3 - GalloDaSballo
2023-01-05T09:02:26Z
In contrast to other admin findings this one:
#4 - GalloDaSballo
2023-01-05T10:48:03Z
For the reasons above am judging the finding Medium Severity
#5 - c4-judge
2023-01-05T10:48:07Z
GalloDaSballo marked the issue as selected for report
#6 - c4-judge
2023-01-06T12:30:57Z
GalloDaSballo marked the issue as primary issue
#7 - msclecram
2023-01-11T04:35:48Z
We updated the code with the next changes: -We radded equire for starting time
https://github.com/bisonic-official/plot-contract/commit/ea8abd7faffde4218232e22ba5d8402e37d96878
🌟 Selected for report: Lambda
Also found by: Dravee, cccz, hansfriese
Data not available
Overview
Risk Rating | Number of issues |
---|---|
Low Risk | 11 |
Non-Critical Risk | 8 |
Table of Contents
claimlistMint
shouldn't be payable, or it should call _mintTokensCheckingValue()
instead of _mintTokens
withdrawAll
being payable
for no reasontokenId
at 1 instead of 0numPlots
constant
0.8.12
and use string.concat()
claimlistMint
shouldn't be payable, or it should call _mintTokensCheckingValue()
instead of _mintTokens
_mintTokens()
is a private function used in ownerMint()
and doesn't forward any msg.value
.
_mintTokensCheckingValue()
is a private function used in mintlistMint()
and mint()
, and checks msg.value
.
The situation for claimlistMint()
is ambiguous as the function is payable
but uses _mintTokens()
. There are no checks for msg.value
in claimlistMint()
and the final runiverseLand.mintTokenId()
call doesn't forward any msg.value
(runiverseLand.mintTokenId
isn't even payable
)
For this ambiguous situation, it depends on how private or privileged the claimers in the claimlist are supposed to be, but one of those 2 things need to be done:
claimlistMint()
shouldn't be payable (no reasons in the current flow)_mintTokensCheckingValue()
should be used instead of _mintTokens
in claimlistMint()
, in which case this issue's severity should be upgradedwithdrawAll
being payable
for no reasonThere's no reason for a withdraw function to also transmit ETH to the contract. Consider removing the payable
keyword:
contracts/RuniverseLand.sol: - 209: function withdrawAll() public payable onlyOwner { //@audit-issue payable for no reason and it'll leave funds inside + 209: function withdrawAll() public onlyOwner {
tokenId
at 1 instead of 0It is a trend to start tokenIds with 1, as this prevents some integration issues with front-ends and is generally nicer
contracts/RuniverseLand.sol: - 43: uint256 public numMinted = 0; + 43: uint256 public numMinted = 1; 77: uint256 tokenId = numMinted; //@audit this is better when started with 1
Do not forget to fix totalSupply()
:
File: RuniverseLand.sol 145: function totalSupply() public view returns (uint256) { - 146: return numMinted; + 146: return numMinted - 1; 147: }
Also fix the following check:
File: RuniverseLand.sol - 93: require(numMinted < MAX_SUPPLY, "All land has been minted"); + 93: require(numMinted <= MAX_SUPPLY, "All land has been minted");
This check feels restrictive:
contracts/RuniverseLandMinter.sol: 310: msg.value == plotPrices[uint256(plotSize)] * numPlots, //@audit-issue Better just send back the excess tokens at the end
There might even be issues with the front-end due to Ether's 18 decimals & JavaScript's precision.
I'd recommend authorizing an excess msg.value
and returning the excess to msg.sender
at the end of the call
There's only 1 event in the whole project. Consider adding some in the setters so that users can be notified when a change is occurring.
This would prevent polluting dashboards:
File: RuniverseLand.sol 088: function mintTokenId( ... 099: emit LandMinted(recipient, tokenId, size); //@audit-issue should emit at the end to avoid pollution 100: 101: _mint(recipient, tokenId); 102: }
numPlots
numPlots == 0
is a valid value: https://github.com/code-423n4/2022-12-forgotten-runiverse/blob/dcad1802bf258bf294900a08a03ca0d26d2304f4/contracts/RuniverseLandMinter.sol#L264-L295
When there are 3 or more arguments, it's encouraged to index your events.
IRuniverseLand.sol:18: event LandMinted(address to, uint256 tokenId, IRuniverseLand.PlotSize size);
ToB report about it : https://github.com/trailofbits/publications/blob/master/reviews/LooksRare.pdf
Contracts inheriting from OpenZeppelin's libraries have the default transferOwnership()
function (a one-step process). It's possible that the onlyOwner
role mistakenly transfers ownership to a wrong address, resulting in a loss of the onlyOwner
role.
Consider overriding the default transferOwnership()
function to first nominate an address as the pendingOwner
and implementing an acceptOwnership()
function which is called by the pendingOwner
to confirm the transfer.
RuniverseLand.sol:11:import "@openzeppelin/contracts/access/Ownable.sol"; RuniverseLand.sol:32: Ownable,
RuniverseLandMinter.sol:6:import "@openzeppelin/contracts/access/Ownable.sol"; RuniverseLandMinter.sol:13:contract RuniverseLandMinter is Ownable, ReentrancyGuard {
The following are external
in the interface:
File: IRuniverseLand.sol 20: function mint(address recipient, PlotSize size) external returns (uint256);//@audit mismatch with impl 21: 22: function mintTokenId( 23: address recipient, 24: uint256 tokenId, 25: PlotSize size 26: ) external; //@audit mismatch with impl
But public
in the implementation:
contracts/RuniverseLand.sol: 72: function mint(address recipient, PlotSize size) 73 public 88: function mintTokenId( 89 address recipient, 90 uint256 tokenId, 91 PlotSize size 92 ) public override nonReentrant {
File: RuniverseLandMinter.sol 184: function mintlisted( 185: address _who, - 186: bytes32 _leaf, 187: bytes32[] calldata _merkleProof 188: ) external view returns (bool) { 189: bytes32 node = keccak256(abi.encodePacked(_who)); 190: - 191: if (node != _leaf) return false;
The NatSpec documentation on ReentrancyGuard.sol states:
If you mark a function nonReentrant, you should also mark it external.
However, the following functions have the nonReentrant modifier and are NOT external:
RuniverseLand.sol:92: ) public override nonReentrant { RuniverseLandMinter.sol:208: payable RuniverseLandMinter.sol:209: nonReentrant RuniverseLandMinter.sol:228: ) public payable nonReentrant { RuniverseLandMinter.sol:269: ) public payable nonReentrant {
As a reminder, I do suggest deleting RuniverseLand.sol#mint()
in another submission, so mintTokenId()
can also be declared as external just like it is in the interface IRuniverseLand.sol
constant
RuniverseLandMinter.sol:229: require(mintlistStarted(), "Mint not started"); RuniverseLandMinter.sol:211: require(publicStarted(), "Mint not started");
RuniverseLandMinter.sol:212: require(numPlots > 0 && numPlots <= 20, "Mint from 1 to 20 plots"); RuniverseLandMinter.sol:230: require(numPlots > 0 && numPlots <= 20, "Mint from 1 to 20 plots");
RuniverseLandMinter.sol:245: "Invalid proof." RuniverseLandMinter.sol:285: "Invalid proof."
RuniverseLandMinter.sol:331: "All plots of that size minted" RuniverseLandMinter.sol:371: "All plots of that size minted"
RuniverseLandMinter.sol:336: "Trying to mint too many plots" RuniverseLandMinter.sol:376: "Trying to mint too many plots"
RuniverseLandMinter.sol:492: require(!mintlistStarted(), "Can't change during mint"); RuniverseLandMinter.sol:505: require(!mintlistStarted(), "Can't change during mint"); RuniverseLandMinter.sol:529: require(!mintlistStarted(), "Can't change during mint");
RuniverseLandMinter.sol:503: "must set exactly 5 numbers" RuniverseLandMinter.sol:519: "must set exactly 5 numbers" RuniverseLandMinter.sol:530: require(_newPrices.length == 5, "must set exactly 5 prices");
RuniverseLandMinter.sol:539: require(address(vault) != address(0), "no vault"); RuniverseLandMinter.sol:547: require(address(vault) != address(0), "no vault");
File: RuniverseLandMinter.sol - 202: * @dev public method for public minting. //@audit-issue extra space + 202: * @dev public method for public minting. - 516: //msc: should we make sure all the numbres are equal or greater? //@audit-issue typo numbres + 516: //msc: should we make sure all the numbers are equal or greater? File: RuniverseLandMinter.sol - 106: * @dev returns how many plots were avialable since the begining. + 106: * @dev returns how many plots were available since the beginning. File: ERC721Vestable.sol - 54: * @notice returns true if a tokenId has besting property. + 54: * @notice returns true if a tokenId has vesting property.
File: ERC721Vestable.sol 103: /** - 104: * @notice set the new vesting start time + 104: * @notice set the new vesting end time 105: */ 106: function _setVestingEnd(uint256 _newVestingEnd) internal virtual {
The following is never used:
File: RuniverseLand.sol 133: /** 134: * @dev Returns the base uri of the token. 135: * @return _baseURI string prefix uri. 136: */ 137: function _baseURI() internal view virtual override returns (string memory) { 138: return baseTokenURI; 139: }
0.8.12
and use string.concat()
Solidity version 0.8.12 introduces string.concat()
(vs abi.encodePacked(<str>,<str>)
)
contracts/RuniverseLand.sol: 121: return string(abi.encodePacked(baseTokenURI, tokenId.toString()));
File: RuniverseLandMinter.sol#getTotalMintedLands()
- 134: function getTotalMintedLands() public view returns (uint256) { + 134: function getTotalMintedLands() public view returns (uint256 totalMintedLands) { - 135: uint256 totalMintedLands; 136: totalMintedLands = plotsMinted[0] + 137: plotsMinted[1] + 138: plotsMinted[2] + 139: plotsMinted[3] + 140: plotsMinted[4]; - 141: return totalMintedLands; 142: }
File: RuniverseLandMinter.sol#getTotalMintedLandsBySize()
- 149: function getTotalMintedLandsBySize() public view returns (uint256[] memory) { + 149: function getTotalMintedLandsBySize() public view returns (uint256[5] memory plotsMintedBySize) { - 150: uint256[] memory plotsMintedBySize = new uint256[](5); 151: 152: plotsMintedBySize[0] = plotsMinted[0]; 153: plotsMintedBySize[1] = plotsMinted[1]; 154: plotsMintedBySize[2] = plotsMinted[2]; 155: plotsMintedBySize[3] = plotsMinted[3]; 156: plotsMintedBySize[4] = plotsMinted[4]; 157: - 158: return plotsMintedBySize; 159: }
Replace magic numbers with constants:
File: RuniverseLandMinter.sol 402: require( localCounter <= 4294967295, "Local index overflow" ); 403: require( uint256(plotSize) <= 255, "Plot index overflow" );
ERC721Vestable.sol:5:pragma solidity ^0.8.0; RuniverseLand.sol:8:pragma solidity ^0.8.0; RuniverseLandMinter.sol:2:pragma solidity ^0.8.0;
#0 - GalloDaSballo
2022-12-24T00:46:05Z
1.1. Either claimlistMint shouldn't be payable, or it should call _mintTokensCheckingValue() instead of _mintTokens L (self-inflicted loss that can be avoided)
1.2. withdrawAll being payable for no reason R
1.3. Consider starting tokenId at 1 instead of 0 R
1.4. Returning ETH at the end of the call would be more user-friendly than asking an exact amount NC, personally would rather keep it this way to avoid more DOsses etc..
1.5. Missing events on setters NC
1.6. It's better to emit after all processing is done NC, I believe this is because of Slither False Positives
1.7. Missing input validation for numPlots R
1.8. Events not indexed Agree with the given instance, NC
1.9. Use a 2-step ownership transfer pattern NC
1.10. Mismatch between interface and implementation Disputing, interface cannot have public, it's always external
1.11. Passing the leaf as an argument is redundant as it contains no additional information R
2.1. Non-traditional Use of ReentrancyGuard R, ultimately agree, although I have had instances where I have done something similar
2.2. Duplicated code: Hardcoded strings having multiple occurrences should be declared as constant R
2.3. Typos and syntax NC
2.4. Remove unused function Disputing as it's used by OZ implementation
2.5. Upgrade to Solidity version 0.8.12 and use string.concat() NC
2.6. Use named returns where relevant NC
2.7. Magic numbers NC
2.8. Non-library/interface files should use fixed compiler versions, not floating ones NC
#1 - GalloDaSballo
2022-12-27T01:04:40Z
1L 6R 9NC
#2 - c4-judge
2023-01-06T13:37:18Z
GalloDaSballo marked the issue as grade-a