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: 39/168
Findings: 4
Award: $464.53
π Selected for report: 0
π Solo Findings: 0
π Selected for report: zkhorse
Also found by: MEP, Picodes, Solimander, berndartmueller, hxzy, hyh, pcarranzav, pfapostol
Delegation should transfer an account's votes to the other account. Instead, the way the code is written in _delegate()
, the votes are only
transferred if there is a previous delegation; otherwise, the prevDelegate
variable is zero, and _moveDelegateVotes
behaves as when a new token is minted (_from
parameter is zero).
The result is that an attacker holding two tokens in separate accounts can delegate from one account to the other and viceversa, and get four votes instead of two.
The following failing test illustrates the issue: founder delegates to founder2, so they should not have any votes anymore, and founder2 should get two votes. Instead, founder still has 1 vote, and founder2 has 2.
When founder2 then delegates to founder, they should again each have 1 vote (delegated from the other founder). Instead, they each have 2 votes, doubling their voting power.
diff --git a/test/Token.t.sol b/test/Token.t.sol index 08eadd1..4b9098e 100644 --- a/test/Token.t.sol +++ b/test/Token.t.sol @@ -85,6 +85,44 @@ contract TokenTest is NounsBuilderTest, TokenTypesV1 { assertEq(token.getVotes(address(auction)), 1); } + function test_DelegateDoubleVote() public { + deployMock(); + + vm.prank(founder); + auction.unpause(); + + assertEq(token.totalSupply(), 3); + + assertEq(token.ownerOf(0), founder); + assertEq(token.ownerOf(1), founder2); + assertEq(token.ownerOf(2), address(auction)); + + assertEq(token.balanceOf(founder), 1); + assertEq(token.balanceOf(founder2), 1); + assertEq(token.balanceOf(address(auction)), 1); + + assertEq(token.getVotes(founder), 1); + assertEq(token.getVotes(founder2), 1); + assertEq(token.getVotes(address(auction)), 1); + + // Now founder delegates to founder2 + vm.prank(founder); + token.delegate(founder2); + + // So founder's vote should now be in founder2's hands + assertEq(token.getVotes(founder), 0); + assertEq(token.getVotes(founder2), 2); + + // founder2 delegates their own vote - + // so now each should get 1 vote again + vm.prank(founder2); + token.delegate(founder); + + assertEq(token.getVotes(founder), 1); + assertEq(token.getVotes(founder2), 1); + + } + function test_MaxOwnership100Founders() public { createUsers(100, 1 ether);
VSCode, foundry
When there is no previous delegation, transfer the user's own votes instead of creating new ones:
diff --git a/src/lib/token/ERC721Votes.sol b/src/lib/token/ERC721Votes.sol index 3e64720..b9e5dd7 100644 --- a/src/lib/token/ERC721Votes.sol +++ b/src/lib/token/ERC721Votes.sol @@ -185,6 +185,9 @@ abstract contract ERC721Votes is IERC721Votes, EIP712, ERC721 { emit DelegateChanged(_from, prevDelegate, _to); + if (prevDelegate == address(0)) { + prevDelegate = _from; + } // Transfer voting weight from the previous delegate to the new delegate _moveDelegateVotes(prevDelegate, _to, balanceOf(_from)); }
#0 - GalloDaSballo
2022-09-20T19:29:09Z
π 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
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/token/Token.sol#L102-L120
The way the vesting schedule is implemented doesn't validate that the baseTokenId is between 0 and 99.
But only baseTokenIds between 0 and 99 will actually produce tokens that are assigned to a founder.
Therefore, depending on the combination of percentages, while looping over the token IDs, some of the percentage points for a founder can be assigned to a baseTokenId that will never be redeemed.
Moreover, when incrementing the tokenId in line 118, the modulo 100 operation isn't stored back into the baseTokenId variable, so the addition doesn't actually wrap around.
Founders will therefore lose and never vest part of their ownership percentage.
The following diff shows a test that fails because of this issue. Two founders get assigned 85 and 7 percent of tokens, respectively. The first founder gets 85 percent of tokens, but the second one ends up getting only 2 percent:
diff --git a/test/Token.t.sol b/test/Token.t.sol index 08eadd1..0bc28f9 100644 --- a/test/Token.t.sol +++ b/test/Token.t.sol @@ -188,6 +188,52 @@ contract TokenTest is NounsBuilderTest, TokenTypesV1 { } } + function test_UnevenOwnership2Founders() public { + createUsers(2, 1 ether); + + address[] memory wallets = new address[](2); + uint256[] memory percents = new uint256[](2); + uint256[] memory vestExpirys = new uint256[](2); + + uint256 founder0Pct = 85; + uint256 founder1Pct = 7; + uint256 end = 4 weeks; + + wallets[0] = otherUsers[0]; + percents[0] = founder0Pct; + vestExpirys[0] = end; + + wallets[1] = otherUsers[1]; + percents[1] = founder1Pct; + vestExpirys[1] = end; + + deployWithCustomFounders(wallets, percents, vestExpirys); + + assertEq(token.totalFounders(), 2); + assertEq(token.totalFounderOwnership(), founder0Pct + founder1Pct); + + Founder memory founder; + + uint256 founder0Tokens = 0; + uint256 founder1Tokens = 0; + uint256 unnassignedTokens = 0; + unchecked { + for (uint256 i; i < 1000; ++i) { + founder = token.getScheduledRecipient(i); + if (founder.wallet == otherUsers[0]) { + founder0Tokens += 1; + } else if (founder.wallet == otherUsers[1]) { + founder1Tokens += 1; + } else { + unnassignedTokens += 1; + } + } + } + assertEq(founder0Tokens, founder0Pct * 10); + assertEq(founder1Tokens, founder1Pct * 10); + assertEq(unnassignedTokens, (100 - founder0Pct - founder1Pct)*10); + } + function testRevert_OnlyAuctionCanMint() public { deployMock();
VSCode, foundry
Validate that the baseTokenId is < 100, and if not, start counting from 0 again. Also, assign the result of the modulo 100 to the baseTokenId variable. This fixes the test shown above:
diff --git a/src/token/Token.sol b/src/token/Token.sol index afad142..dc3f814 100644 --- a/src/token/Token.sol +++ b/src/token/Token.sol @@ -108,14 +108,16 @@ contract Token is IToken, UUPS, ReentrancyGuard, ERC721Votes, TokenStorageV1 { for (uint256 j; j < founderPct; ++j) { // Get the available token id baseTokenId = _getNextTokenId(baseTokenId); - + if (baseTokenId >= 100) { + baseTokenId = _getNextTokenId(0); + } // Store the founder as the recipient tokenRecipient[baseTokenId] = newFounder; emit MintScheduled(baseTokenId, founderId, newFounder); // Update the base token id - (baseTokenId += schedule) % 100; + baseTokenId = (baseTokenId + schedule) % 100; } }
#0 - GalloDaSballo
2022-09-20T12:57:43Z
π 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/main/src/token/Token.sol#L78-L125
founderPct
is a uint256, but the validation in line 88 casts it to uint8 before checking if the addition goes over the maximum. Later, in line 102, the uint256 number is used to set the vesting schedule. Moreover, the uint256 value is used in the loop to define the mint schedule in line 108
This means a founder can use any percentage such that founderPct % 256 <= 100
and the initialization will succeed, producing an invalid number of tokens assigned to the founder.
Adding this failing test shows the problem (note we're using a founder percentage of 512!):
diff --git a/test/Token.t.sol b/test/Token.t.sol index 08eadd1..89a0234 100644 --- a/test/Token.t.sol +++ b/test/Token.t.sol @@ -198,6 +198,28 @@ contract TokenTest is NounsBuilderTest, TokenTypesV1 { token.mint(); } + function test_InvalidOwnershipPct() 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 = 512; + uint256 end = 4 weeks; + + unchecked { + for (uint256 i; i < 2; ++i) { + wallets[i] = otherUsers[i]; + percents[i] = pct; + vestExpirys[i] = end; + } + } + + vm.expectRevert(abi.encodeWithSignature("INVALID_FOUNDER_OWNERSHIP()")); + deployWithCustomFounders(wallets, percents, vestExpirys); + } + function testRevert_OnlyAuctionCanBurn() public { deployMock();
VSCode, foundry
Validate founderPct before casting it to uint8:
diff --git a/src/token/Token.sol b/src/token/Token.sol index afad142..6cad99d 100644 --- a/src/token/Token.sol +++ b/src/token/Token.sol @@ -84,6 +84,8 @@ contract Token is IToken, UUPS, ReentrancyGuard, ERC721Votes, TokenStorageV1 { // Continue if no ownership is specified if (founderPct == 0) continue; + // Ensure ownership percentage is within bounds + if (founderPct > 100) revert INVALID_FOUNDER_OWNERSHIP(); // Update the total ownership and ensure it's valid if ((totalOwnership += uint8(founderPct)) > 100) revert INVALID_FOUNDER_OWNERSHIP();
#0 - GalloDaSballo
2022-09-26T19:22:40Z
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
60.7775 USDC - $60.78
I've separately reported what I believe are a high severity issue and a medium severity issue, both related to the token allocation for founders. That's the section of the code that I've (so far) been able to spend more time on, and I believe that particular section could use a refactor to make errors like these less likely.
The rest of the code looks okay to me so far. I'm including in this QA report one low-severity bug, and a few non-critical suggestions. The first of these (Immutability of implementations) is an architectural suggestion that I think could make the code more future-proof. The rest are simply style suggestions.
Other than that, even though I could find no vulnerabilities in these, I would recommend extra care and testing in:
In _generateSeed()
I assume blockhash is used to introduce randomness, but blockhash(block.number)
will always be zero. I recommend using blockhash(block.number - 1)
.
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/manager/Manager.sol#L25-L37
The pattern of using immutable variables in Manager is clever, but it doesnβt allow updating the default implementation for the contracts. This means any vulnerability found in the contracts after deployment would always be present in a newly-created DAO, until the founder upgrades to the newer implementation.
I understand itβd be a big change to make the base implementations mutable (especially because you then have to somehow figure out what creation hash to use to get the addresses in getAddresses).
But there is an easier alternative: providing a way to atomically deploy and upgrade a DAO to the latest version of each implementation. This could be done by keeping the latest version of each type of contract in Manager storage; the deploy()
function can then check if there is a newer version for each contract and upgrade to it before returning.
e.g. in https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/manager/Manager.sol#L105-L109
I recommend not using named returns (https://github.com/ethereum/solidity/issues/1401) and instead specifying the meaning of the return values in the NatSpec documentation.
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/auction/Auction.sol#L22 https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/auction/Auction.sol#L92
Recommendation: change the name of one of the two, e.g. rename the contract to AuctionHouse
https://github.com/code-423n4/2022-09-nouns-builder/blob/main/src/managed/Managed.sol#L117
Having this assignment in conditional is confusion-prone, it would be safer to assign when declaring address founder
.
While I can see the benefit of saving gas with unchecked math
where it's guaranteed there'll be no overflows, the use of long
blocks of code with unchecked
makes it more likely that
future code changes will introduce operations that can overflow.
I'd recommend applying unchecked{}
to the specific statements where the lack of overflows is guaranteed.
#0 - GalloDaSballo
2022-09-27T00:39:02Z
L
R
R
Rest I disagree with / is mostly opinion
1L 2R