Platform: Code4rena
Start Date: 03/07/2023
Pot Size: $100,000 USDC
Total HM: 4
Participants: 36
Period: 10 days
Judge: gzeon
Total Solo HM: 3
Id: 257
League: ETH
Rank: 1/36
Findings: 2
Award: $31,683.74
🌟 Selected for report: 2
🚀 Solo Findings: 2
🌟 Selected for report: cccz
15841.8736 USDC - $15,841.87
During the escrow period, users can escrow to or withdraw from forkEscrow their Nouns. During the escrow period, proposals can be executed.
function withdrawFromForkEscrow(NounsDAOStorageV3.StorageV3 storage ds, uint256[] calldata tokenIds) external { if (isForkPeriodActive(ds)) revert ForkPeriodActive(); INounsDAOForkEscrow forkEscrow = ds.forkEscrow; forkEscrow.returnTokensToOwner(msg.sender, tokenIds); emit WithdrawFromForkEscrow(forkEscrow.forkId(), msg.sender, tokenIds); }
Since withdrawFromForkEscrow will only call the returnTokensToOwner function of ds.forkEscrow, and returnTokensToOwner is only allowed to be called by DAO. If, during the escrow period, ds.forkEscrow is changed by the proposal's call to _setForkEscrow, then the user's escrowed Nouns will not be withdrawn by withdrawFromForkEscrow.
function returnTokensToOwner(address owner, uint256[] calldata tokenIds) external onlyDAO { for (uint256 i = 0; i < tokenIds.length; i++) { if (currentOwnerOf(tokenIds[i]) != owner) revert NotOwner(); nounsToken.transferFrom(address(this), owner, tokenIds[i]); escrowedTokensByForkId[forkId][tokenIds[i]] = address(0); } numTokensInEscrow -= tokenIds.length; }
Consider that some Nouners is voting on a proposal that would change ds.forkEscrow. There are some escrowed Nouns in forkEscrow (some Nouners may choose to always escrow their Nouns to avoid missing fork). The proposal is executed, ds.forkEscrow is updated, and the escrowed Nouns cannot be withdrawn.
https://github.com/nounsDAO/nouns-monorepo/blob/718211e063d511eeda1084710f6a682955e80dcb/packages/nouns-contracts/contracts/governance/fork/NounsDAOV3Fork.sol#L95-L102 https://github.com/nounsDAO/nouns-monorepo/blob/718211e063d511eeda1084710f6a682955e80dcb/packages/nouns-contracts/contracts/governance/fork/NounsDAOForkEscrow.sol#L116-L125 https://github.com/nounsDAO/nouns-monorepo/blob/718211e063d511eeda1084710f6a682955e80dcb/packages/nouns-contracts/contracts/governance/NounsDAOV3Admin.sol#L527-L531
None
Consider allowing the user to call forkEscrow.returnTokensToOwner directly to withdraw escrowed Nouns, and need to move isForkPeriodActive from withdrawFromForkEscrow to returnTokensToOwner.
Context
#0 - 0xSorryNotSorry
2023-07-20T10:44:25Z
A bottle neck in an intended behaviour. Rleaying to the sponsors for the perusal.
#1 - c4-pre-sort
2023-07-20T12:28:31Z
0xSorryNotSorry marked the issue as primary issue
#2 - c4-sponsor
2023-07-20T20:44:59Z
eladmallel marked the issue as sponsor acknowledged
#3 - c4-judge
2023-07-24T09:15:20Z
gzeon-c4 marked the issue as satisfactory
#4 - eladmallel
2023-07-25T21:49:15Z
we think it would be great to include this issue in the report
#5 - c4-judge
2023-07-25T22:13:45Z
gzeon-c4 marked the issue as selected for report
#6 - gzeon-c4
2023-07-25T22:14:20Z
we think it would be great to include this issue in the report
It would be, since this is an unique issue I simply forgot to click the button haha
🌟 Selected for report: cccz
15841.8736 USDC - $15,841.87
cancel() does not allow to cancel proposals in the final states Canceled/Defeated/Expired/Executed/Vetoed.
function cancel(NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId) external { NounsDAOStorageV3.ProposalState proposalState = stateInternal(ds, proposalId); if ( proposalState == NounsDAOStorageV3.ProposalState.Canceled || proposalState == NounsDAOStorageV3.ProposalState.Defeated || proposalState == NounsDAOStorageV3.ProposalState.Expired || proposalState == NounsDAOStorageV3.ProposalState.Executed || proposalState == NounsDAOStorageV3.ProposalState.Vetoed ) { revert CantCancelProposalAtFinalState(); }
The Canceled/Executed/Vetoed states are final because they cannot be changed once they are set. The Defeated state is also a final state because no new votes will be cast (stateInternal() may return Defeated only if the objectionPeriodEndBlock is passed).
But the Expired state depends on the GRACE_PERIOD of the timelock, and GRACE_PERIOD may be changed due to upgrades. Once the GRACE_PERIOD of the timelock is changed, the state of the proposal may also be changed, so Expired is not the final state.
} else if (block.timestamp >= proposal.eta + getProposalTimelock(ds, proposal).GRACE_PERIOD()) { return NounsDAOStorageV3.ProposalState.Expired; } else { return NounsDAOStorageV3.ProposalState.Queued;
Consider the following scenario. Alice submits proposal A to stake 20,000 ETH to a DEFI protocol, and it is successfully passed, but it cannot be executed because there is now only 15,000 ETH in the timelock (consumed by other proposals), and then proposal A expires. The DEFI protocol has been hacked or rug-pulled. Now proposal B is about to be executed to upgrade the timelock and extend GRACE_PERIOD (e.g., GRACE_PERIOD is extended by 7 days from V1 to V2). Alice wants to cancel Proposal A, but it cannot be canceled because it is in Expired state. Proposal B is executed, causing Proposal A to change from Expired to Queued. The malicious user sends 5000 ETH to the timelock and immediately executes Proposal A to send 20000 ETH to the hacked protocol.
None
Consider adding a proposal expiration time field in the Proposal structure
function queue(NounsDAOStorageV3.StorageV3 storage ds, uint256 proposalId) external { require( stateInternal(ds, proposalId) == NounsDAOStorageV3.ProposalState.Succeeded, 'NounsDAO::queue: proposal can only be queued if it is succeeded' ); NounsDAOStorageV3.Proposal storage proposal = ds._proposals[proposalId]; INounsDAOExecutor timelock = getProposalTimelock(ds, proposal); uint256 eta = block.timestamp + timelock.delay(); for (uint256 i = 0; i < proposal.targets.length; i++) { queueOrRevertInternal( timelock, proposal.targets[i], proposal.values[i], proposal.signatures[i], proposal.calldatas[i], eta ); } proposal.eta = eta; + proposal.exp = eta + timelock.GRACE_PERIOD(); ... - } else if (block.timestamp >= proposal.eta + getProposalTimelock(ds, proposal).GRACE_PERIOD()) { + } else if (block.timestamp >= proposal.exp) { return NounsDAOStorageV3.ProposalState.Expired;
Context
#0 - c4-pre-sort
2023-07-20T12:28:33Z
0xSorryNotSorry marked the issue as primary issue
#1 - eladmallel
2023-07-20T20:36:25Z
Agree it's possible due to a change in executor's grace period to move from Expired back to Queued. However, since a grace period change is a rare event, we think this is very low priority and we won't fix.
#2 - c4-sponsor
2023-07-20T20:36:46Z
eladmallel marked the issue as disagree with severity
#3 - c4-sponsor
2023-07-20T21:24:55Z
eladmallel marked the issue as sponsor acknowledged
#4 - eladmallel
2023-07-21T12:25:44Z
Update: can you please remove the disagree with severity label? we'd like to just acknowledge and not fix at this time. Thanks!
#5 - c4-judge
2023-07-24T09:16:42Z
gzeon-c4 changed the severity to QA (Quality Assurance)
#6 - c4-judge
2023-07-25T09:17:17Z
gzeon-c4 marked the issue as grade-c
#7 - eladmallel
2023-07-25T21:49:25Z
we think it would be great to include this issue in the report (at medium severity)
#8 - gzeon-c4
2023-07-25T22:26:39Z
we think it would be great to include this issue in the report (at medium severity)
Changing the GRACE_PERIOD is an admin change, which besides misconfiguration is out-of-scope, it is as you described is a rare event. Having a malicious proposal which is passed that got expired is also a rare event. Having a changed GRACE_PERIOD that just long enough to make such a malicious proposal become queued is a very rare event, assuming governance is not completely compromised already.
That said, I am ok with this being Med risk since this is clearly in scope + can be Med risk with some assumption (tho extreme imo but is subjective), and I would recommend for a fix accordingly. Please let me know if that's what you want, thanks!
#9 - eladmallel
2023-07-26T00:49:53Z
thank you @gzeon-c4 we all agree the odds of the risk materializing is low, we just felt like this was a nice find, and honestly mostly motivated by wanting the warden who found this to have a win :)
it's not a deal breaker for us if it's in the report or not, just wanted to express our preference.
thank you for sharing more of your thinking, it's helpful!
#10 - thereksfour
2023-07-26T03:50:10Z
Low Likelihood + High Severity is generally considered M, which is an edge case that fits the medium risk. Another thing I would say is that the proposal doesn't need to be malicious, as I said in the attack scenario where the proposal is normal but expires due to inability to execute for other reasons ( contract balance insufficient, etc.).
Changing the GRACE_PERIOD is an admin change, which besides misconfiguration is out-of-scope, it is as you described is a rare event. Having a malicious proposal which is passed that got expired is also a rare event. Having a changed GRACE_PERIOD that just long enough to make such a malicious proposal become queued is a very rare event, assuming governance is not completely compromised already.
#11 - gzeon-c4
2023-07-26T06:09:26Z
Low Likelihood + High Severity is generally considered M, which is an edge case that fits the medium risk. Another thing I would say is that the proposal doesn't need to be malicious, as I said in the attack scenario where the proposal is normal but expires due to inability to execute for other reasons ( contract balance insufficient, etc.).
True, but this is also marginally out-of-scope since an admin action is required, and one may argue it is a misconfiguration if you increase GRACE_PERIOD so much that it revive some old passed buggy proposal.
But given this is marginal and on sponsor's recommendation, I will upgrade this to M.
#12 - c4-judge
2023-07-26T06:09:39Z
This previously downgraded issue has been upgraded by gzeon-c4
#13 - c4-judge
2023-07-26T06:09:45Z
gzeon-c4 marked the issue as selected for report