Platform: Code4rena
Start Date: 06/09/2022
Pot Size: $90,000 USDC
Total HM: 33
Participants: 168
Period: 9 days
Judge: GalloDaSballo
Total Solo HM: 10
Id: 157
League: ETH
Rank: 56/168
Findings: 5
Award: $235.47
🌟 Selected for report: 1
🚀 Solo Findings: 0
🌟 Selected for report: zzzitron
Also found by: 0xSmartContract, ChristianKuri, ElKu, Lambda, MiloTruck, davidbrai, elad, hansfriese, immeas, ladboy233, scaraven, volky
The Token as well as Auction cannot be used if the sum of ownershipPct
is 100
function test_poc_mintforever() public { createUsers(2, 1 ether); address[] memory wallets = new address[](2); uint256[] memory percents = new uint256[](2); uint256[] memory vestExpirys = new uint256[](2); uint256 pct = 50; uint256 end = 4 weeks; unchecked { for (uint256 i; i < 2; ++i) { wallets[i] = otherUsers[i]; percents[i] = pct; vestExpirys[i] = end; } } deployWithCustomFounders(wallets, percents, vestExpirys); assertEq(token.totalFounders(), 2); assertEq(token.totalFounderOwnership(), 100); Founder memory founder; unchecked { for (uint256 i; i < 100; ++i) { founder = token.getScheduledRecipient(i); if (i % 2 == 0) assertEq(founder.wallet, otherUsers[0]); else assertEq(founder.wallet, otherUsers[1]); } } // // commented out as it will not stop // vm.prank(otherUsers[0]); // auction.unpause(); }
In the proof of concept, there are two founders and they both share 50% of ownership. If the Auction
should be unpause
d, and therefore triggers to mint tokens, it will go into the infinite loop and eventually revert for out of gas.
// Token.sol 143 function mint() external nonReentrant returns (uint256 tokenId) { 144 // Cache the auction address 145 address minter = settings.auction; 146 147 // Ensure the caller is the auction 148 if (msg.sender != minter) revert ONLY_AUCTION(); 149 150 // Cannot realistically overflow 151 unchecked { 152 do { 153 // Get the next token to mint 154 tokenId = settings.totalSupply++; 155 156 // Lookup whether the token is for a founder, and mint accordingly if so 157 } while (_isForFounder(tokenId)); 158 } 159 160 // Mint the next available token to the auction house for bidding 161 _mint(minter, tokenId); 162 } 177 function _isForFounder(uint256 _tokenId) private returns (bool) { 178 // Get the base token id 179 uint256 baseTokenId = _tokenId % 100; 180 181 // If there is no scheduled recipient: 182 if (tokenRecipient[baseTokenId].wallet == address(0)) { 183 return false; 184 185 // Else if the founder is still vesting: 186 } else if (block.timestamp < tokenRecipient[baseTokenId].vestExpiry) { 187 // Mint the token to the founder 188 _mint(tokenRecipient[baseTokenId].wallet, _tokenId); 189 190 return true; 191 192 // Else the founder has finished vesting: 193 } else { 194 // Remove them from future lookups 195 delete tokenRecipient[baseTokenId]; 196 197 return false; 198 } 199 }
In the Token::mint
, there is a while loop which will keep looping as long as _isForFounder
returns true. The _isForFounder
function will return true is the given _tokenId
's recipient is still vesting. However, to check the recipient it is checking the baseTokenId
which is _tokenId % 100
(in line 179 above snippet). Which means, if the tokenRecipient
of 0 to 99 are currently vesting, it will keep returning true and the while loop in the mint
function will not stop. The tokenRecipient
was set in the _addFounders
and if the sum of all founders' ownership percent is 100, the tokenRecipient
will be filled up to 100.
None
use _tokenId
instead of baseTokenId
.
#0 - GalloDaSballo
2022-09-20T19:46:57Z
Making this primary as it has a coded POC
The comment I deleted marked as dup of 400, I think this one is better developed, thank you for marking though as it helped find the other reports
#1 - GalloDaSballo
2022-09-25T19:41:37Z
While a different typo may have allow to support more than 100 founders and shares setup, the following check: https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/token/Token.sol#L179-L180
uint256 baseTokenId = _tokenId % 100;
Will cause each id to be check against the first 100, meaning that if the owners own 100% of all first 100 ids (e.g. the schedule
value is 1, the code will loop forever in this while loop as no new ID is available
#2 - GalloDaSballo
2022-09-25T19:42:38Z
Because this is contingent on setting admin ownership to 100%, I think Medium Severity to be more appropriate, I wonder if 100% ownership for founders is rational in any case, as no auction would ever happen, however the configuration is allowed and it will brick the contracts
🌟 Selected for report: MEP
Also found by: 0xSky, CertoraInc, MiloTruck, PwnPatrol, R2, Tointer, V_B, __141345__, antonttc, azephiar, cccz, d3e4, datapunk, davidbrai, easy_peasy, hansfriese, minhtrng, neumo, pcarranzav, peritoflores, scaraven, teawaterwire, tonisives, unforgiven, wagmi, zkhorse, zzzitron
5.6134 USDC - $5.61
It calculates wrong baseTokenId
In the _isForFounder
checks the tokenRecipient
only within [0-99] range, so presumable, the baseTokenId
in the _addFounders
were supposedly to wrap around 100. However, the modulo operator in the line 118 does not do that. The line 118 with modulo will give same result without modulo operator, so it is ineffective.
Moreover, a new baseTokenId
will be assigned by _getNextTokenId
(line 110), which will just increase the tokenId.
// Token::_addFounders 110 baseTokenId = _getNextTokenId(baseTokenId); //... 118 (baseTokenId += schedule) % 100; // Token::_isForFounder 179 uint256 baseTokenId = _tokenId % 100; //... 188 _mint(tokenRecipient[baseTokenId].wallet, _tokenId);
None
It is not clear what was the intention of the line 118, but presumably baseTokenId = ( baseTokenId + schedule ) % 100
.
But since the baseTokenId
is updated in the line 120 by the _getNextTokenId
, the baseTokenId
is not bound to the 100.
#0 - GalloDaSballo
2022-09-25T23:07:55Z
No more voting is possible if proposalThresholdBps
and quorumThresholdBps
is set to be larger than 10000
// Governor::initialize 79 settings.proposalThresholdBps = SafeCast.toUint16(_proposalThresholdBps); 80 settings.quorumThresholdBps = SafeCast.toUint16(_quorumThresholdBps); // Governor // proposalThreshold 466 function proposalThreshold() public view returns (uint256) { 467 unchecked { 468 return (settings.token.totalSupply() * settings.proposalThresholdBps) / 10_000; 469 } 470 } // Governor // quorum 473 function quorum() public view returns (uint256) { 474 unchecked { 475 return (settings.token.totalSupply() * settings.quorumThresholdBps) / 10_000; 476 } 477 } // Governor // setters for proposalThresholdBps and quorumThresholdBps 580 function updateProposalThresholdBps(uint256 _newProposalThresholdBps) external onlyOwner { 581 emit ProposalThresholdBpsUpdated(settings.proposalThresholdBps, _newProposalThresholdBps); 582 583 settings.proposalThresholdBps = SafeCast.toUint16(_newProposalThresholdBps); 584 } 588 function updateQuorumThresholdBps(uint256 _newQuorumVotesBps) external onlyOwner { 589 emit QuorumVotesBpsUpdated(settings.quorumThresholdBps, _newQuorumVotesBps); 590 591 settings.quorumThresholdBps = SafeCast.toUint16(_newQuorumVotesBps); 592 }
If the proposalThresholdBps
is larger than 10000, even if a person with 100% of share cannot propose. If quorumThresholdBps
is larger than 10000, even if 100% of voters vote for it, the propose will not pass.
It means once a value of more than 10000 is set in either of proposalThresholdBps
or quorumThresholdBps
, no proposal can be proposed, or no proposal can have enough vote for making any more change.
To update the settings, it should pass vote and executed through the treasury, however there can be no more passing votes, no more update in the settings is possible. Therefore, it is stuck in these values and can have no more votes.
Any weth or eth in the treasury will also be inaccessible.
Currently, the upper limit for these variables are the max of uint16, which is 65535 or approximately 600%.
None
Should set some reasonable upper limit for proposalThresholdBps
and quorumThresholdBps
.
#0 - GalloDaSballo
2022-09-25T20:37:08Z
Dup of #482
🌟 Selected for report: CertoraInc
Also found by: 0xSky, PwnPatrol, Tomo, V_B, antonttc, bin2chen, pcarranzav, peritoflores, rbserver, scaraven, wagmi, zzzitron
49.075 USDC - $49.08
https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/token/Token.sol#L88 https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/token/Token.sol#L102
Founders can take a lot of tokens more than they are supposed to.
Below is a snippet of proof of concept for a founder takes a lot of tokens. The full test code can be found here. It was modified from Token.t.sol
.
function test_poc_FounderPctOverflow() public { createUsers(2, 1 ether); address[] memory wallets = new address[](2); uint256[] memory percents = new uint256[](2); uint256[] memory vestExpirys = new uint256[](2); uint256 pct = 50; uint256 end = 4 weeks; unchecked { for (uint256 i; i < 2; ++i) { wallets[i] = otherUsers[i]; percents[i] = pct; vestExpirys[i] = end; } } // Overflow was not checked upon cast percents[0] = uint256(type(uint8).max) + 51; deployWithCustomFounders(wallets, percents, vestExpirys); assertEq(token.totalFounders(), 2); assertEq(token.totalFounderOwnership(), 100); Founder memory founder; unchecked { for (uint256 i; i < 500; ++i) { founder = token.getScheduledRecipient(i); assertEq(founder.wallet, otherUsers[0]); // if (i % 2 == 0) assertEq(founder.wallet, otherUsers[0]); // else assertEq(founder.wallet, otherUsers[1]); } } }
In the Token::_addFounders
the founders' ownershipPct was casted without checking for overflow:
// Token::_addFounders // In the line 88 and 99, the `founderPct` was casted into uint8. 87 // Update the total ownership and ensure it's valid 88 if ((totalOwnership += uint8(founderPct)) > 100) revert INVALID_FOUNDER_OWNERSHIP(); 99 newFounder.ownershipPct = uint8(founderPct); // In the line 102, the `founderPct` was used without casting 101 // Compute the vesting schedule 102 uint256 schedule = 100 / founderPct; // In the line 108, the `founderPct` was used without casting 107 // For each token to vest: 108 for (uint256 j; j < founderPct; ++j) { 109 // Get the available token id 110 baseTokenId = _getNextTokenId(baseTokenId); 111 112 // Store the founder as the recipient 113 tokenRecipient[baseTokenId] = newFounder; 114 115 emit MintScheduled(baseTokenId, founderId, newFounder); 116 117 // Update the base token id 118 (baseTokenId += schedule) % 100; 119 }
For the totalOwnership
and ownershipPct
, the founderPct
was casted into uint8. If the given founderPct
is larger than type(uint8).max
the founderPct
will wrap around and it will pass the check for totalOwnership
being less or equal to 100 (line 88 in the above code snippet).
However, the founderPct
was used in the schedule
and in the loop without casting into uint8 (line 102 and 108 in the above snippet). Therefore, the founder can get a lot more tokens then they are supposed to get. Besides, the schedule
will also set to be zero, in the case founderPct
would be larger than zero.
In the above example proof of concept, the founder[0]
will be given 50% of ownership, but they will get type(uint8).max + 51
tokens.
None
The best solution would be make the FounderParams.ownershipPct
as uint8 value.
Easier solution would be add a line to check the ownershipPct can be casted safely. For example:
<!-- zzzitron H00 -->// Token::__addFounders 87 // Update the total ownership and ensure it's valid + require(founderPct <= type(uint8).max, "SafeCast: value doesn't fit in 8 bits"); 88 if ((totalOwnership += uint8(founderPct)) > 100) revert INVALID_FOUNDER_OWNERSHIP();
#0 - GalloDaSballo
2022-09-26T19:21:43Z
Dup of #303
🌟 Selected for report: Lambda
Also found by: 0x1337, 0x1f8b, 0x4non, 0x85102, 0xA5DF, 0xNazgul, 0xSmartContract, 0xbepresent, 0xc0ffEE, 8olidity, Aymen0909, B2, Bnke0x0, CRYP70, Captainkay, CertoraInc, Ch_301, Chom, ChristianKuri, CodingNameKiki, Deivitto, Diana, DimitarDimitrov, ElKu, EthLedger, Franfran, Funen, GimelSec, JansenC, Jeiwan, Jujic, Lead_Belly, MEP, MasterCookie, MiloTruck, Noah3o6, PPrieditis, PaludoX0, Picodes, PwnPatrol, R2, Randyyy, RaymondFam, Respx, ReyAdmirado, Rolezn, Samatak, Tointer, Tomo, V_B, Waze, _Adam, __141345__, a12jmx, ak1, asutorufos, azephiar, ballx, bharg4v, bin2chen, bobirichman, brgltd, bulej93, c3phas, cccz, ch0bu, cloudjunky, cryptonue, cryptostellar5, cryptphi, csanuragjain, d3e4, datapunk, davidbrai, delfin454000, dharma09, dic0de, dipp, djxploit, eierina, erictee, fatherOfBlocks, gogo, hansfriese, hyh, imare, indijanc, izhuer, jonatascm, ladboy233, leosathya, lucacez, lukris02, m9800, martin, minhtrng, ne0n, neumo, oyc_109, p_crypt0, pashov, pauliax, pcarranzav, pedr02b2, peritoflores, pfapostol, rbserver, ret2basic, robee, rvierdiiev, sach1r0, sahar, scaraven, sikorico, simon135, slowmoses, sorrynotsorry, tnevler, tonisives, volky, yixxas, zkhorse, zzzitron
61.0218 USDC - $61.02
A bidder can outbid previous bid with the same value, if the (previous bid * minBidIncrement < 100)
.
// Auction.sol // createBid 117 unchecked { 118 // Compute the minimum bid 119 minBid = highestBid + ((highestBid * settings.minBidIncrement) / 100); 120 } 121 122 // Ensure the incoming bid meets the minimum 123 if (msg.value < minBid) revert MINIMUM_BID_NOT_MET(); 331 function setMinimumBidIncrement(uint256 _percentage) external onlyOwner { 332 settings.minBidIncrement = SafeCast.toUint8(_percentage); 333 334 emit MinBidIncrementPercentageUpdated(_percentage); 335 }
When the minBid
(defined in the line 119) is the same as the current highestBid
, one can call createBid
with the same value as the current highestBid
(line 123). It means that the new bidder will get the Bid, even though the previous bidder has the same bid and was earlier.
The minBid
can be the same as the highesBid
when highestBid * minBidIncrement
is less than 100. So either the highestBid
or minBinIncrement
is too small, a bidder can overbid the precious one with the same amount of value.
The first bid should be higher or equal to the reservePrice
. However, there is no safe guard against setting small reservePrice
and minBidIncrement
.
For example, let's say the settings.minBidIncrement
is set to zero. Alice called createBid
with 1 ether and is the current highestBidder with the highestBid
of 1 ether. Bob calls createBid
with 1 ether. The minBid
in the line 119 will be 1ether as minBidIncrement
is set to zero. In the line 123 the msg.value
is 1 ether is the same as minBid
therefore it will not revert. And now Bob is the highestBidder
even though he bid the same value after Alice.
None
Revert if the msg.value
is the same as the minBid
:
<!-- zzzitron M00 -->// Auction.sol // createBid 117 unchecked { 118 // Compute the minimum bid 119 minBid = highestBid + ((highestBid * settings.minBidIncrement) / 100); 120 } 121 122 // Ensure the incoming bid meets the minimum - if (msg.value < minBid) revert MINIMUM_BID_NOT_MET(); + if (msg.value <= minBid) revert MINIMUM_BID_NOT_MET();
#0 - GalloDaSballo
2022-09-26T23:14:01Z
Per discussion on https://github.com/code-423n4/2022-09-nouns-builder-findings/issues/638
Downgrading to QA Low
#1 - GalloDaSballo
2022-09-26T23:14:03Z
L