Core Contracts

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

Proposals would become stuck and unexecutable when execute function is called in Goverance.sol

Summary

In governance.sol system's state transition logic, specifically in how proposals move through different states and interact with the timelock controller. The core issue stems from a race condition and state inconsistency between the Governance.sol and TimelockController, where a proposal can become "stuck" due to improper state management.

When a proposal is queued directly through the timelock controller instead of following the proper state transition path through the governance contract, it creates a deadlock situation where:

  • The governance contract sees the proposal as "Succeeded" and tries to queue it

  • The timelock controller already has the proposal queued

  • The execution fails because the governance contract's state doesn't match the timelock's state

  • The proposal becomes permanently unexecutable

This vulnerability could be exploited to permanently freeze governance proposals, effectively allowing an attacker to disrupt the protocol's governance mechanism.

Vulnerability Details

The vulnerability stems from a state synchronization issue between the Governance.sol and TimelockController.sol contracts. Here's the exact breakdown:

  • When a proposal passes voting, it enters the "Succeeded" state in the Governance contract.

  • The Governance contract, directly calls TimelockController's scheduleBatch() function to queue the proposal:

This direct call creates two simultaneous but conflicting states:

  • The proposal is now queued in TimelockController

  • The proposal's internal state in Governance remains as "Succeeded"

    • When state() is called, it sees the proposal is pending in timelock and returns "Queued"

However, when execute() is called, it:

  • Checks the internal state (still sees "Succeeded")

  • Tries to queue the proposal again

  • Fails because the proposal is already in the timelock

The proposal becomes permanently stuck because:

  • It can't be queued again (already in timelock)

  • It can't be executed (wrong internal state)

  • No mechanism exists to reconcile this state mismatch

First, in the state() function, it automatically checks timelock status:

function state(uint256 proposalId) public view override returns (ProposalState) {
// ... other state checks ...
bytes32 id = _timelock.hashOperationBatch(
proposal.targets,
proposal.values,
proposal.calldatas,
bytes32(0),
proposal.descriptionHash
);
// Key Issue #1: Automatically returns Queued if operation is in timelock
if (_timelock.isOperationPending(id)) {
return ProposalState.Queued;
}
// If not pending and voting passed, it's Succeeded
return ProposalState.Succeeded;
}

But in execute(), it tries to queue again if it sees Succeeded:

function execute(uint256 proposalId) external override nonReentrant {
ProposalCore storage proposal = _proposals[proposalId];
if (proposal.executed) revert ProposalAlreadyExecuted(proposalId, block.timestamp);
ProposalState currentState = state(proposalId);
// Key Issue #2: Tries to queue if sees Succeeded, but state() might return Queued
if (currentState == ProposalState.Succeeded) {
// Queue the proposal
_queueProposal(proposalId);
} else if (currentState == ProposalState.Queued) {
// Execute the queued proposal
_executeProposal(proposalId);
} else {
revert InvalidProposalState(
proposalId,
currentState,
currentState == ProposalState.Active ? ProposalState.Succeeded : ProposalState.Queued,
"Invalid state for execution"
);
}
}

The _queueProposal() function then fails because it's already in timelock:

function _queueProposal(uint256 proposalId) internal {
// ... hash calculation ...
// Key Issue #3: Reverts if already in timelock
if (_timelock.isOperationPending(id)) {
revert ProposalAlreadyExecuted(proposalId, block.timestamp);
}
// Schedule in timelock
_timelock.scheduleBatch(/*...*/);
}

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";
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 testStateTransitionAndDoubleQueueVulnerability() public {
// Setup initial voting power for attacker
vm.startPrank(attackerAccounts[0]);
raacToken.approve(address(veToken), 500_000e18);
veToken.lock(500_000e18, 1460 days);
vm.stopPrank();
// Create a 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] = 1 ether;
calldatas[0] = abi.encodeWithSignature("someFunction()");
vm.startPrank(attackerAccounts[0]);
uint256 proposalId = governance.propose(
targets,
values,
calldatas,
"Test Proposal",
IGovernance.ProposalType.TreasuryAction
);
// Move to voting period and pass the proposal
vm.warp(block.timestamp + governance.votingDelay() + 1);
governance.castVote(proposalId, true);
vm.warp(block.timestamp + governance.votingPeriod());
// POC Part 1: State Transition Issue
// At this point, proposal should be in Succeeded state
assertEq(uint(governance.state(proposalId)), uint(IGovernance.ProposalState.Succeeded));
// Key part 2: Queue it through timelock first. This happens beacuse scheduleBatch is called directly in state fucntion.
bytes32 proposalHash = timelock.hashOperationBatch(
targets,
values,
calldatas,
bytes32(0),
keccak256(bytes("Test Proposal"))
);
vm.stopPrank();
vm.prank(address(governance));
timelock.scheduleBatch(
targets,
values,
calldatas,
bytes32(0),
keccak256(bytes("Test Proposal")),
timelock.getMinDelay()
);
// POC Part 2: Double Queuing Issue
vm.startPrank(attackerAccounts[0]);
// Now state() will return Queued because operation is pending in timelock
assertEq(uint(governance.state(proposalId)), uint(IGovernance.ProposalState.Queued));
// Try to execute - this will fail because execute() expects Succeeded state first
vm.expectRevert(abi.encodeWithSignature("ProposalNotQueued(uint256,bytes32)", proposalId, proposalHash));
governance.execute(proposalId);
// Even if we wait for the timelock delay
vm.warp(block.timestamp + timelock.getMinDelay());
// The proposal is stuck - can't be queued (already in timelock)
// and can't be executed (never went through proper state transition)
vm.expectRevert();
governance.execute(proposalId);
vm.stopPrank();
// Log the impact using console.log instead
console.log("=== Vulnerability Impact ===");
console.log("Proposal State: ", uint(governance.state(proposalId)));
console.log("Is Operation Pending: ", timelock.isOperationPending(proposalHash));
console.log("Is Operation Ready: ", timelock.isOperationReady(proposalHash));
console.log("Proposal is stuck - cannot be executed due to state transition issue");
}

Flow:

Initial State:

// Proposal has passed voting and is in Succeeded state
assertEq(uint(governance.state(proposalId)), uint(IGovernance.ProposalState.Succeeded));

Direct Timelock Call by Governance:

// Governance contract directly calls timelock
vm.prank(address(governance));
timelock.scheduleBatch(
targets,
values,
calldatas,
bytes32(0),
keccak256(bytes("Test Proposal")),
timelock.getMinDelay()
);

. State Desynchronization:

// state() sees it's in timelock and returns Queued
assertEq(uint(governance.state(proposalId)), uint(IGovernance.ProposalState.Queued));
// But execute() still tries to queue it again because internally it's still Succeeded
vm.expectRevert(abi.encodeWithSignature("ProposalNotQueued(uint256,bytes32)", proposalId, proposalHash));
governance.execute(proposalId);

This creates a situation where:

  • The proposal is queued in timelock

  • But governance's internal state hasn't tracked this change

  • When execute() is called, it tries to queue again

  • The operation fails because it's already in timelock

Result: The proposal becomes permanently unexecutable because:

  • Can't queue (already in timelock)

  • Can't execute (wrong internal state)

  • No way to resolve the state mismatch

Impact

All proposals become stuck and unexecutable.

Tools Used

Recommendations

ensure state Synchronization:

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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