Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: high
Invalid

Governance Voting Manipulation via Disproportionate Voting Power. Vulnerability could Lead to severe disruptions, funds thefts, etc, through malicious proposals execution.

Summary

In the Governance contract, voting power is used to determine the outcome of proposals rather than a simple "one vote per voter" mechanism. However, this approach introduces a vulnerability where a proposer with a disproportionately high voting power can manipulate the governance process in their favor.

Currently, a proposal's outcome is determined by comparing the total voting power of "forVotes" and "againstVotes" after quorum is met. However, because voting power is distributed unevenly across users, a single proposer with a significant amount of voting power can pass their proposal even when a larger number of individual voters oppose it.

This flaw undermines the fairness of governance and can lead to centralized decision-making, where a few powerful voters dominate the voting process, ultimately harming the protocol's decentralization and legitimacy.

Vulnerability Details

The issue arises due to how the governance contract processes votes and determines quorum. Below are the relevant functions involved in this vulnerability:

Proposal Creation (propose function)

function propose(
address[] memory targets,
uint256[] memory values,
bytes[] memory calldatas,
string memory description,
ProposalType proposalType
) external override returns (uint256) {
uint256 proposerVotes = _veToken.getVotingPower(msg.sender);
if (proposerVotes < proposalThreshold) {
revert InsufficientProposerVotes(msg.sender, proposerVotes, proposalThreshold, "Below threshold");
}
uint256 proposalId = _proposalCount++;
uint256 startTime = block.timestamp + votingDelay;
uint256 endTime = startTime + votingPeriod;
//...
//...
//...
_proposals[proposalId] = ProposalCore({
id: proposalId,
proposer: msg.sender,
proposalType: proposalType,
startTime: startTime,
endTime: endTime,
executed: false,
canceled: false,
descriptionHash: keccak256(bytes(description)),
targets: targets,
values: values,
calldatas: calldatas
});
emit ProposalCreated(proposalId, msg.sender, targets, values, calldatas, description, proposalType, startTime, endTime, proposerVotes);
return proposalId;
}

Vote Casting (castVote function)

function castVote(uint256 proposalId, bool support) external override returns (uint256) {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
if (block.timestamp < proposal.startTime) {
revert VotingNotStarted(proposalId, proposal.startTime, block.timestamp);
}
if (block.timestamp > proposal.endTime) {
revert VotingEnded(proposalId, proposal.endTime, block.timestamp);
}
ProposalVote storage proposalVote = _proposalVotes[proposalId];
if (proposalVote.hasVoted[msg.sender]) {
revert AlreadyVoted(proposalId, msg.sender, block.timestamp);
}
uint256 weight = _veToken.getVotingPower(msg.sender);
if (weight == 0) {
revert NoVotingPower(msg.sender, block.number);
}
proposalVote.hasVoted[msg.sender] = true;
if (support) {
proposalVote.forVotes += weight;
} else {
proposalVote.againstVotes += weight;
}
//...
//...
//...
emit VoteCast(msg.sender, proposalId, support, weight, "");
return weight;
}

Proposal State Calculation (state function)

function state(uint256 proposalId) public view override returns (ProposalState) {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.startTime == 0) revert ProposalDoesNotExist(proposalId);
if (proposal.canceled) return ProposalState.Canceled;
if (proposal.executed) return ProposalState.Executed;
if (block.timestamp < proposal.startTime) return ProposalState.Pending;
if (block.timestamp < proposal.endTime) return ProposalState.Active;
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
@> if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
//...
//...
//...
return ProposalState.Succeeded;
}

Proof of Concept (PoC)

Consider the following scenario:

  • Alice (Proposer): Holds 8% of the total voting power.

  • BigE, XavierWoods, and KofiKingston: Each holds 2% voting power, totaling 6%.

Exploit Execution:

  1. Alice creates a proposal and immediately votes in favor with her 8% voting power.

  2. The three other voters (BigE, XavierWoods, and KofiKingston) vote against the proposal, with a combined voting power of 6%.

  3. Since Alice has a larger share of voting power than all three combined, the proposal is counted as approved despite having fewer individual voters in favor.

  4. The quorum requirement is met, and forVotes > againstVotes, allowing Alice to manipulate governance and push her proposal for execution.

PoC Test Suite

To demonstrate this vulnerability, the following Proof of Concept (PoC) is provided. The PoC is written using the Foundry tool.

  1. Step 1: Create a Foundry project and place all the contracts in the src directory.

  2. Step 2: Create a test directory and a mocks folder within the src directory (or use an existing mocks folder).

  3. Step 3: Create all necessary mock contracts, if required.

  4. Step 4: Create a test file (with any name) in the test directory.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {veRAACToken} from "../src/core/tokens/veRAACToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {TimelockController} from "../src/core/governance/proposals/TimelockController.sol";
import {Governance} from "../src/core/governance/proposals/Governance.sol";
import {TimeWeightedAverage} from "../src/libraries/math/TimeWeightedAverage.sol";
import {LockManager} from "../src/libraries/governance/LockManager.sol";
import {IveRAACToken} from "../src/interfaces/core/tokens/IveRAACToken.sol";
import {IGovernance} from "../src/interfaces/core/governance/proposals/IGovernance.sol";
contract GovernanceTest is Test {
veRAACToken veRaacToken;
RAACToken raacToken;
TimelockController timelockController;
Governance governance;
address RAAC_OWNER = makeAddr("RAAC_OWNER");
address RAAC_MINTER = makeAddr("RAAC_MINTER");
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
address VE_RAAC_OWNER = makeAddr("VE_RAAC_OWNER");
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
address BIG_E = makeAddr("BIG_E");
address XAVIER_WOODS = makeAddr("XAVIER_WOODS");
address KOFI_KINGSTON = makeAddr("KOFI_KINGSTON");
address PROPOSER_1 = makeAddr("PROPOSER_1");
address PROPOSER_2 = makeAddr("PROPOSER_2");
address PROPOSER_3 = makeAddr("PROPOSER_3");
address PROPOSER_4 = makeAddr("PROPOSER_4");
address PROPOSER_5 = makeAddr("PROPOSER_5");
address EXECUTOR_1 = makeAddr("EXECUTOR_1");
address EXECUTOR_2 = makeAddr("EXECUTOR_2");
address EXECUTOR_3 = makeAddr("EXECUTOR_3");
address EXECUTOR_4 = makeAddr("EXECUTOR_4");
address EXECUTOR_5 = makeAddr("EXECUTOR_5");
address TIMELOCK_OWNER = makeAddr("TIMELOCK_OWNER");
address GOVERNANCE_OWNER = makeAddr("GOVERNANCE_OWNER");
uint256 timelockControllerMinDelay = 2 days;
address[] private proposers;
address[] private executors;
address admin;
address[] private proposalTargets;
uint256[] private proposalValues;
bytes[] private proposalCalldatas;
string private proposalDescription;
IGovernance.ProposalType proposalProposalType;
function setUp() public {
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.startPrank(VE_RAAC_OWNER);
veRaacToken = new veRAACToken(address(raacToken));
vm.stopPrank();
proposers.push(PROPOSER_1);
proposers.push(PROPOSER_2);
proposers.push(PROPOSER_3);
proposers.push(PROPOSER_4);
proposers.push(PROPOSER_5);
executors.push(EXECUTOR_1);
executors.push(EXECUTOR_2);
executors.push(EXECUTOR_3);
executors.push(EXECUTOR_4);
executors.push(EXECUTOR_5);
vm.startPrank(TIMELOCK_OWNER);
TimelockController tempTimelockController =
new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
timelockController = new TimelockController(timelockControllerMinDelay, proposers, executors, TIMELOCK_OWNER);
vm.stopPrank();
vm.startPrank(GOVERNANCE_OWNER);
governance = new Governance(address(veRaacToken), address(tempTimelockController));
governance.setTimelock(address(timelockController));
vm.stopPrank();
proposalTargets.push(address(tempTimelockController));
proposalValues.push(0);
proposalCalldatas.push(abi.encodeWithSignature("setValue(uint256)", 42));
proposalDescription = "Proposal String";
proposalProposalType = IGovernance.ProposalType.ParameterChange;
vm.startPrank(TIMELOCK_OWNER);
timelockController.grantRole(timelockController.PROPOSER_ROLE(), address(governance));
timelockController.grantRole(timelockController.EXECUTOR_ROLE(), address(governance));
timelockController.grantRole(timelockController.CANCELLER_ROLE(), address(governance));
vm.stopPrank();
getveRaacTokenForProposer();
}
function getveRaacTokenForProposer() private {
uint256 LOCK_AMOUNT = 10_000_000e18;
uint256 LOCK_DURATION = 365 days;
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(RAAC_MINTER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER);
raacToken.mint(PROPOSER_1, LOCK_AMOUNT);
raacToken.mint(PROPOSER_2, LOCK_AMOUNT);
raacToken.mint(PROPOSER_3, LOCK_AMOUNT);
raacToken.mint(PROPOSER_4, LOCK_AMOUNT);
raacToken.mint(PROPOSER_5, LOCK_AMOUNT);
raacToken.mint(ALICE, LOCK_AMOUNT);
raacToken.mint(BOB, LOCK_AMOUNT);
raacToken.mint(CHARLIE, LOCK_AMOUNT);
raacToken.mint(DEVIL, LOCK_AMOUNT);
raacToken.mint(BIG_E, 1_000_000e18);
raacToken.mint(XAVIER_WOODS, 1_000_000e18);
raacToken.mint(KOFI_KINGSTON, 1_000_000e18);
vm.stopPrank();
vm.startPrank(PROPOSER_1);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_2);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_3);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_4);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(PROPOSER_5);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(ALICE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BOB);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(CHARLIE);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(DEVIL);
raacToken.approve(address(veRaacToken), LOCK_AMOUNT);
veRaacToken.lock(LOCK_AMOUNT, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(BIG_E);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(XAVIER_WOODS);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
vm.startPrank(KOFI_KINGSTON);
raacToken.approve(address(veRaacToken), 1_000_000e18);
veRaacToken.lock(1_000_000e18, LOCK_DURATION);
vm.stopPrank();
}
}
  1. Step 5: Add the following test PoC in the test file, after the setUp function.

function testVotersWithHighVotingPowerCanRigTheVoting() public {
vm.startPrank(ALICE);
uint256 proposalId = governance.propose(
proposalTargets, proposalValues, proposalCalldatas, proposalDescription, proposalProposalType
);
vm.stopPrank();
IGovernance.ProposalCore memory proposalCore = governance.getProposal(proposalId);
vm.warp(block.timestamp + proposalCore.startTime + 1);
vm.roll(block.number + 1);
uint256 totalVotingPower = veRaacToken.getTotalVotingPower();
uint256 aliceVotingPower = veRaacToken.getVotingPower(ALICE);
uint256 bigEVotingPower = veRaacToken.getVotingPower(BIG_E);
uint256 xavierWoodsVotingPower = veRaacToken.getVotingPower(XAVIER_WOODS);
uint256 kofiKingstonVotingPower = veRaacToken.getVotingPower(KOFI_KINGSTON);
uint256 currentQuorum = aliceVotingPower + bigEVotingPower + xavierWoodsVotingPower + kofiKingstonVotingPower;
uint256 expectedRequiredQuorum =
(totalVotingPower * governance.quorumNumerator()) / governance.QUORUM_DENOMINATOR();
uint256 requiredQuorum = governance.quorum();
assertEq(requiredQuorum, expectedRequiredQuorum);
assert(currentQuorum >= requiredQuorum);
console.log("currentQuorum : ", currentQuorum);
console.log("requiredQuorum: ", requiredQuorum);
// Proposal will get executed even if 3 people are against the proposal
// not a fair voting, voting power should be taken into account only for quorum requirement
// and there should have one vote counted for one person.
vm.startPrank(ALICE);
governance.castVote(proposalId, true);
vm.stopPrank();
vm.startPrank(BIG_E);
governance.castVote(proposalId, false);
vm.stopPrank();
vm.startPrank(XAVIER_WOODS);
governance.castVote(proposalId, false);
vm.stopPrank();
vm.startPrank(KOFI_KINGSTON);
governance.castVote(proposalId, false);
vm.stopPrank();
vm.warp(block.timestamp + proposalCore.endTime + 1);
vm.roll(block.number + 1);
vm.startPrank(ALICE);
governance.execute(proposalId);
vm.stopPrank();
vm.warp(block.timestamp + timelockController.getMinDelay() + 1);
vm.roll(block.number + 1);
bytes32 id = timelockController.hashOperationBatch(
proposalTargets, proposalValues, proposalCalldatas, bytes32(0), proposalCore.descriptionHash
);
vm.startPrank(ALICE);
// execution will get reverted because we passed dummy data as proposal data
vm.expectRevert(abi.encodeWithSelector(bytes4(keccak256("CallReverted(bytes32,uint256)")), id, 0));
governance.execute(proposalId);
vm.stopPrank();
}
  1. Step 6: To run the test, execute the following commands in your terminal:

forge test --mt testVotersWithHighVotingPowerCanRigTheVoting -vv
  1. Step 7: Review the output. The expected output should indicate that malicious actor can rig the Governance voting if they have unproportionately High voting power.

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/GovernanceTest.t.sol:GovernanceTest
[PASS] testVotersWithHighVotingPowerCanRigTheVoting() (gas: 847281)
Logs:
currentQuorum : 3241095684297311009824592
requiredQuorum: 930000000000000000000000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 27.02ms (3.19ms CPU time)
Ran 1 test suite in 43.77ms (27.02ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the test confirms that the malicious actors can rig the Governance Proposal Voting.

Impact

  • Governance Manipulation: A single large voting power holder can unilaterally pass proposals even when more voters oppose them.

  • Centralization Risk: The governance process favors wealthier participants with more tokens, undermining decentralization.

  • Protocol Integrity Compromise: Unfair governance decisions can lead to malicious proposals being executed, damaging the protocol's reputation.

  • Voter Discouragement: Smaller token holders may feel powerless and disengage from governance, reducing overall participation.

Tools Used

  • Manual Review

  • Foundry

  • AI (chatGPT for Grammatic errors)

Recommendations

1. Implement Weighted Majority Approval

Modify the proposal state logic to ensure that the majority of individual voters must be in favor, rather than only comparing weighted voting power.

if (proposalVote.forVotes > proposalVote.againstVotes && proposalVote.forVoters > proposalVote.againstVoters) {
return ProposalState.Succeeded;
}

2. Implement Voting Power Caps

Limit the maximum percentage of voting power any single address can contribute to a single proposal.

3. Introduce Quadratic Voting

Adopt a quadratic voting system where voting power is proportional to the square root of the tokens held, reducing the influence of large holders.

4. Require a Minimum Number of Distinct Voters

Ensure that a proposal needs votes from a minimum number of unique voters before it is considered valid.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Appeal created

theirrationalone Submitter
4 months ago
theirrationalone Submitter
4 months ago
inallhonesty Lead Judge
4 months ago
inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.