Core Contracts

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

Inadequate Vote Casting Validation Allows Voting on Cancelled Proposals, Leading to Governance Manipulation and Denial of Service

Summary

The Governance contract allows users with sufficient voting power (meeting the proposalThreshold) to propose new initiatives using the Governance::propose function. Once a proposal is submitted, voters are permitted to cast their votes (either in favor or against) after a specified votingDelay. In an ideal scenario, once a proposal is cancelled, no further votes should be accepted.

While the contract includes a state function that correctly returns a ProposalState.Canceled status if a proposal has been cancelled, the castVote function fails to consult this state before recording votes. Consequently, voters can still cast votes on proposals that have been cancelled. This oversight enables malicious actors to manipulate the governance process by artificially recording votes on cancelled proposals, potentially distorting voting results, skewing quorum calculations, and forcing genuine voters to waste gas on transactions that ultimately have no effect.

Vulnerability Details

Proposal Lifecycle and the state Function

The proposal lifecycle is managed through the Governance::propose function, which initializes proposals with a start time, end time, and cancellation flag. The state function is designed to determine the current status of a proposal based on various conditions, including whether it has been cancelled. Below is an excerpt from the 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;
// After voting period ends, check quorum and votes
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
uint256 requiredQuorum = quorum();
// Check if quorum is met and votes are in favor
if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
bytes32 id = _timelock.hashOperationBatch(
proposal.targets, proposal.values, proposal.calldatas, bytes32(0), proposal.descriptionHash
);
// If operation is pending in timelock, it's Queued
if (_timelock.isOperationPending(id)) {
return ProposalState.Queued;
}
// If not pending and voting passed, it's Succeeded
return ProposalState.Succeeded;
}

This function correctly identifies cancelled proposals by returning ProposalState.Canceled when proposal.canceled is true. However, the critical vulnerability lies in the castVote function.

Flaw in the castVote Function

The castVote function is responsible for recording votes on proposals. Although it performs checks for the existence of the proposal and whether the voting period is active, it does not verify whether the proposal has been cancelled. As a result, even if the state function would indicate that a proposal is cancelled, the castVote function still allows votes to be cast. Below is the relevant code snippet:

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);
}
@> // Note: There is no check here to verify that the proposal is not cancelled.
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;
}

Because castVote does not incorporate a check for proposal.canceled or use the state function to determine the proposal’s status, voters can continue to cast votes on proposals that have already been cancelled. This loophole not only permits manipulation of voting results but also forces legitimate voters to expend gas on transactions that are ultimately invalid.

Proof of Concept

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 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);
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();
}
}
  1. Step 5: Add the following test PoC in the test file, after the setUp -> private helper function.

function testVotersCanVoteOnCancelledProposal() 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);
vm.startPrank(ALICE);
governance.cancel(proposalId);
vm.stopPrank();
vm.startPrank(ALICE);
governance.castVote(proposalId, true);
vm.stopPrank();
vm.startPrank(BOB);
governance.castVote(proposalId, true);
vm.stopPrank();
vm.startPrank(CHARLIE);
governance.castVote(proposalId, true);
vm.stopPrank();
assertEq(governance.hasVoted(proposalId, ALICE), true);
assertEq(governance.hasVoted(proposalId, BOB), true);
assertEq(governance.hasVoted(proposalId, CHARLIE), true);
(uint256 forVotes, uint256 againstVote) = governance.getVotes(proposalId);
uint256 totalVotes = forVotes + againstVote;
uint256 expectedTotalVotes =
veRaacToken.getVotingPower(ALICE) + veRaacToken.getVotingPower(BOB) + veRaacToken.getVotingPower(CHARLIE);
assertEq(totalVotes, expectedTotalVotes);
assertEq(forVotes, expectedTotalVotes);
assertEq(againstVote, 0);
}
  1. Step 6: To run the test, execute the following commands in your terminal:

forge test --mt testVotersCanVoteOnCancelledProposal -vv
  1. Step 7: Review the output. The expected output should indicate that voters can vote on a cancelled Proposal.

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/GovernanceTest.t.sol:GovernanceTest
[PASS] testVotersCanVoteOnCancelledProposal() (gas: 692055)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 6.20ms (599.40µs CPU time)
Ran 1 test suite in 13.50ms (6.20ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

As demonstrated, the test confirms that voters are allowed to vote on a cancelled Proposal.

Impact

  • Governance Manipulation:
    The absence of a cancellation check in castVote allows malicious actors to cast votes on proposals that have been cancelled. This undermines the governance process by enabling vote rigging and skewing the overall results.

  • Distorted Voting Quorum:
    Voting on cancelled proposals may lead to an inaccurate count of votes, which can distort quorum calculations. This inaccuracy affects the decision-making process, potentially causing valid proposals to be improperly approved or rejected.

  • Denial of Service for Legitimate Voters:
    When voters attempt to cast votes on proposals that are no longer active, they incur unnecessary gas costs. This not only wastes resources but also creates a denial of service (DoS) condition for genuine participants, as their votes may never be counted if the proposal is cancelled.

  • Erosion of User Trust:
    The ability to vote on cancelled proposals can lead to significant mistrust in the governance mechanism. Stakeholders may lose confidence in the protocol’s ability to maintain a fair and transparent decision-making process, which could reduce overall participation and harm the protocol's reputation.

  • Potential for Systematic Abuse:
    Malicious actors could exploit this vulnerability on a large scale, systematically manipulating governance outcomes to favor their interests. This abuse would compromise the democratic integrity of the system and could have far-reaching negative consequences for protocol governance.

Here are some reasons why it might be considered Medium severity:

  • Governance Manipulation:
    Governance is a critical component of many decentralized protocols. If an attacker can manipulate the voting process—even on cancelled proposals—it can significantly distort decision-making. This could lead to proposals being passed or rejected based on manipulated vote counts, thereby undermining the democratic process.

  • Erosion of Trust:
    The integrity of the governance process is vital for maintaining stakeholder trust. Even if funds remain safe, a compromised voting system can erode confidence among users and investors, leading to reduced participation and potential market instability. Trust is paramount in decentralized systems, and any vulnerability that undermines governance can have long-lasting consequences.

  • Indirect Financial Impact:
    Although the vulnerability does not directly drain funds, manipulating governance could result in decisions that negatively affect the protocol's economic model.

  • Systematic Abuse:
    The ability to vote on cancelled proposals can be exploited repeatedly, allowing malicious actors to create a pattern of abuse that systematically undermines the governance framework. This can pave the way for more significant attacks or the gradual erosion of decentralization and fairness within the protocol.

Tools Used

  • Manual Review

  • Foundry

  • AI (chatGPT: for grammatic errors)

Recommendations

  • Integrate a Cancellation Check in castVote:
    Modify the castVote function to incorporate a verification step that ensures votes cannot be cast on proposals that have been cancelled. For instance, add the following condition at the beginning of the function:

    if (state(proposalId) == ProposalState.Canceled) {
    revert ProposalCancelled(proposalId, "Cannot vote on a cancelled proposal");
    }

    This check will ensure that the function adheres to the proposal’s current state as determined by the state function.

  • Refactor the Voting Workflow:
    Ensure that the governance workflow leverages the state function consistently across all functions that interact with proposals. This will help maintain consistency in how proposals are evaluated and prevent any discrepancies between the proposal state and allowed actions.

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance::castVote lacks canceled/executed proposal check, allowing users to waste gas voting on proposals that can never be executed

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Governance::castVote lacks canceled/executed proposal check, allowing users to waste gas voting on proposals that can never be executed

Support

FAQs

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