Open code423n4 opened 1 year ago
Unusual and interesting, but we think this should be med risk since the setup is somewhat exotic and still not a guarantee that assets would be at risk. We will implement the fix suggested.
Agree with downgrading to Medium risk. This scenario can result in redirecting voting power to the wrong account, but is a nuanced scenario that would be difficult to target as an attack.
Lines of code
https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/crowdfund/Crowdfund.sol#L167-L171
Vulnerability details
burn() allows for previously recorded delegate to set himself to be contributor's delegate even if another one was already chosen.
This can be quite material as owner choice for the whole voting power is being reset this way to favor the old delegate.
Proof of Concept
_burn() can be invoked by anyone on the behalf of any
contributor
:https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/crowdfund/Crowdfund.sol#L167-L171
It mints the governance NFT for the contributor whenever he has voting power:
https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/crowdfund/Crowdfund.sol#L471-L485
Now mint() calls _adjustVotingPower() with a new delegate, redirecting all the intristic power, not just one for that id, ignoring the delegation the
owner
might already have:https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/party/PartyGovernanceNFT.sol#L120-L133
I.e. Bob the contributor can take part in the crowdfunding with contribute() with small
0.01 ETH
stake, stating Mike as the delegate of his choice withcontribute(Mike, ...)
:https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/crowdfund/Crowdfund.sol#L189-L208
Then crowdfund was a success, party was created, and Melany, who also participated, per off-chain arrangement has transferred to Bob a
tokenId
with big voting power (say it is100 ETH
and the majority of voting power):https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/party/PartyGovernanceNFT.sol#L146-L155
https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/party/PartyGovernance.sol#L879-L887
Bob don't care about his early small contribution and focuses on managing the one that Melany transferred instead as he simply don't need the voting power from the initial
0.01 ETH
contribution anymore.The actual delegate for Bob at the moment is Linda, while his business with Mike is over. So Bob sets her address there, calling
delegateVotingPower(Linda)
:https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/party/PartyGovernance.sol#L448-L454
Now, Mike can unilaterally delegate to himself the whole voting power with
burn(Bob)
as mint() just resets the delegation with the previously recorded value with_adjustVotingPower(owner, votingPower.safeCastUint256ToInt192(), delegate)
.Recommended Mitigation Steps
The issue is that mint() always assumes that it is the first operation for the
owner
, which might not always be the case.Consider not changing the delegate on
mint
if one is set already:https://github.com/PartyDAO/party-contracts-c4/blob/3896577b8f0fa16cba129dc2867aba786b730c1b/contracts/party/PartyGovernanceNFT.sol#L120-L133
More complicated version might be the one with tracking the most recent request via contribute()/delegateVotingPower() calls timestamps. Here we assume that the delegateVotingPower() holds more information as in the majority of practical cases it occurs after initial contribute() and it is a direct voluntary call from the owner.