Closed code423n4 closed 1 year ago
I believe this should be a High Risk finding
The Warden has shown how, through a faulty assumption, anyone can execute a proposal multiple times.
Because no other indicator beside the timestamp, is checked for Executing and Queuing, a malicious attacker could repeatedly perform the same operation, impact can very likely lead to maximum loss (assume a swap that can be reliably repeated for front-running, or an allowance that may create opportunity for transfers)
Because of the elevated impact, I believe High Severity to be appropriate
It is not possible to run into this via an operational mistake.
The malicious Owner case is the only case where this is possible, and to be explicit, this means governance would have to vote to transfer ownership of Treasury to an EOA or a contract that we didn't write.
Treasury.queue
can only be called by Governor.queue
, which validates that the proposal has state Succeeded
. Similarly, Treasury.execute
can only be called by Governor.execute
, which sets the proposal state to Executed
. So it is not possible to queue a proposal that has already been executed, and I have confirmed this with the following test (drop it into Gov.t.sol
to run it):
function test_ProposalVoteQueueExecutionQueueVulnerability() public {
// propose
mintVoter1();
(address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = mockProposal();
bytes32 descriptionHash = keccak256(bytes("test"));
vm.warp(1 days);
vm.prank(voter1);
governor.propose(targets, values, calldatas, "test");
bytes32 proposalId = governor.hashProposal(targets, values, calldatas, descriptionHash);
// vote
vm.warp(block.timestamp + governor.votingDelay());
vm.prank(voter1);
governor.castVote(proposalId, 1);
// queue
vm.warp(block.timestamp + governor.votingPeriod());
vm.prank(voter1);
governor.queue(proposalId);
// execute
vm.warp(block.timestamp + 2 days);
governor.execute(targets, values, calldatas, descriptionHash);
assertEq(auction.paused(), true);
// queue again (fails)
vm.prank(voter1);
governor.queue(proposalId);
}
The above test fails with the output [FAIL. Reason: PROPOSAL_UNSUCCESSFUL()]
.
That error comes from this line of Governor, which checks the state of the proposal. You can confirm that Governor.state
is returning Executed
in this case by replacing this line with revert PROPOSAL_DOES_NOT_EXIST();
and rerunning the test. The test will now fail with the output [FAIL. Reason: PROPOSAL_DOES_NOT_EXIST()]
instead of the original output.
If there is a way to re-queue an already executed proposal, please provide a test that demonstrates it. Otherwise you can close the issue. Thank you!
It is not possible to run into this via an operational mistake.
The malicious Owner case is the only case where this is possible, and to be explicit, this means governance would have to vote to transfer ownership of Treasury to an EOA or a contract that we didn't write.
Treasury.queue
can only be called byGovernor.queue
, which validates that the proposal has stateSucceeded
. Similarly,Treasury.execute
can only be called byGovernor.execute
, which sets the proposal state toExecuted
. So it is not possible to queue a proposal that has already been executed, and I have confirmed this with the following test (drop it intoGov.t.sol
to run it):function test_ProposalVoteQueueExecutionQueueVulnerability() public { // propose mintVoter1(); (address[] memory targets, uint256[] memory values, bytes[] memory calldatas) = mockProposal(); bytes32 descriptionHash = keccak256(bytes("test")); vm.warp(1 days); vm.prank(voter1); governor.propose(targets, values, calldatas, "test"); bytes32 proposalId = governor.hashProposal(targets, values, calldatas, descriptionHash); // vote vm.warp(block.timestamp + governor.votingDelay()); vm.prank(voter1); governor.castVote(proposalId, 1); // queue vm.warp(block.timestamp + governor.votingPeriod()); vm.prank(voter1); governor.queue(proposalId); // execute vm.warp(block.timestamp + 2 days); governor.execute(targets, values, calldatas, descriptionHash); assertEq(auction.paused(), true); // queue again (fails) vm.prank(voter1); governor.queue(proposalId); }
The above test fails with the output
[FAIL. Reason: PROPOSAL_UNSUCCESSFUL()]
.That error comes from this line of Governor, which checks the state of the proposal. You can confirm that
Governor.state
is returningExecuted
in this case by replacing this line withrevert PROPOSAL_DOES_NOT_EXIST();
and rerunning the test. The test will now fail with the output[FAIL. Reason: PROPOSAL_DOES_NOT_EXIST()]
instead of the original output.If there is a way to re-queue an already executed proposal, please provide a test that demonstrates it. Otherwise you can close the issue. Thank you!
Thank you for the well thought out reply, I'll definitely need to re-evaluate this report as well as a couple others relating to governance
I'd like to apologize for jumping to conclusions too early.
Ultimately the POC offered by the Sponsor shows how, while a lack of validation is present in the Treasury
contract, the normal setup, having Governor.queue
used to queue the Proposal for execution will counteract the specific vulnerability shown by the Warden.
An argument can be made, that governance could delegate the Treasury to an EOA, however, that's not "normal behavior", not just that, handling ownership to an EOA for any contract would violate the in-scope expected settings for most contracts, and as such this report shouldn't warrant a Medium Severity.
I believe some of the observations made by the Warden are still correct, however I'll close this one as invalid in favour of similar observations made in: https://github.com/code-423n4/2022-09-nouns-builder-findings/issues/510
Lines of code
https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/governance/treasury/Treasury.sol#L114-L130
Vulnerability details
There is no executed flag for the proposals, so one can be executed an arbitrary number of times.
This will have critical impact, for example a proposal to send out
10 ETH
can be run10
times, transferring100 ETH
. Apart from the malicious Owner case, this also can be a result of an operational mistake, for example repeatedly executing the same proposal over some time if off-chain records that it was already executed were omitted.As the functions in questions are permissioned, setting the severity to be medium.
Proof of Concept
On queue() the proposal is marked for execution by setting the
timestamps
value for it:https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/governance/treasury/Treasury.sol#L114-L130
There is no marking for the executed proposal, the
timestamps[proposalId]
is just deleted, so queue() can be run again right after execute():https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/governance/treasury/Treasury.sol#L141-L172
This
queue()
-> waitsettings.delay
->execute()
sequence can be run an arbitrary number of times.Recommended Mitigation Steps
Consider introducing the flag, marking the executed proposals.