Summary
The governance system is vulnerable to vote manipulation through post-vote token withdrawals. Users can amplify their voting power by withdrawing tokens after casting votes, as the system uses current total supply for quorum calculations while keeping votes at their original value.
Vulnerability Details
The vulnerability exists because:
Votes are recorded at their full value when cast and remain unchanged:
function castVote(uint256 proposalId, bool support) external override returns (uint256) {
uint256 weight = _veToken.getVotingPower(msg.sender);
proposalVote.hasVoted[msg.sender] = true;
if (support) {
proposalVote.forVotes += weight;
} else {
proposalVote.againstVotes += weight;
}
}
But quorum is calculated using current total supply:
function quorum() public view override returns (uint256) {
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}
Success conditions check:
return currentQuorum >= requiredQuorum &&
proposalVote.forVotes > proposalVote.againstVotes;
Example Attack:
Initial state:
- Alice: 100 votes
- Bob: 100 votes
- Charlie: 150 votes
- Total: 350 votes
- Required for pass: 179 votes (51% of 350)
Attack sequence:
1. Alice votes YES (100)
2. Bob votes YES (100)
- Total YES = 200 votes
3. Charlie votes NO (150)
- Total NO = 150 votes
4. Bob withdraws their 100 tokens
- New total supply = 250 tokens
- YES votes still = 200
- NO votes still = 150
Proposal passes because:
- Meets quorum (350 votes > 179)
- YES votes (200) > NO votes (150)
Attack succeeded because:
- At voting time: YES = 57% (200/350)
- After withdrawal: YES = 80% (200/250)
- Votes counted at full value despite withdrawal
Impact
Allows malicious actors to artificially inflate their voting power
Undermines the democratic process of governance
Makes quorum requirements ineffective as a security measure
Could lead to proposals passing with artificially inflated support
Tools Used
Recommendations
Snapshot total supply at proposal creation:
struct ProposalCore {
uint256 snapshotTotalSupply;
}
function propose(...) {
proposal.snapshotTotalSupply = _veToken.getTotalVotingPower();
}
function quorum(uint256 proposalId) public view returns (uint256) {
return (proposals[proposalId].snapshotTotalSupply * quorumNumerator) / QUORUM_DENOMINATOR;
}
function withdraw(uint256 amount) external {
require(!hasActiveVotes(msg.sender), "Cannot withdraw with active votes");
}