Nouns DAO - cccz's results

A DAO-driven NFT project on Ethereum.

General Information

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

Nouns DAO

Findings Distribution

Researcher Performance

Rank: 1/36

Findings: 2

Award: $31,683.74

🌟 Selected for report: 2

🚀 Solo Findings: 2

Findings Information

🌟 Selected for report: cccz

Labels

bug
2 (Med Risk)
primary issue
satisfactory
selected for report
sponsor acknowledged
M-02

Awards

15841.8736 USDC - $15,841.87

External Links

Lines of code

https://github.com/nounsDAO/nouns-monorepo/blob/718211e063d511eeda1084710f6a682955e80dcb/packages/nouns-contracts/contracts/governance/fork/NounsDAOV3Fork.sol#L95-L102

Vulnerability details

Impact

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.

Proof of Concept

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

Tools Used

None

Consider allowing the user to call forkEscrow.returnTokensToOwner directly to withdraw escrowed Nouns, and need to move isForkPeriodActive from withdrawFromForkEscrow to returnTokensToOwner.

Assessed type

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

Findings Information

🌟 Selected for report: cccz

Labels

bug
2 (Med Risk)
primary issue
selected for report
sponsor acknowledged
edited-by-warden
M-03

Awards

15841.8736 USDC - $15,841.87

External Links

Lines of code

https://github.com/nounsDAO/nouns-monorepo/blob/718211e063d511eeda1084710f6a682955e80dcb/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol#L571-L581

Vulnerability details

Impact

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.

Proof of Concept

https://github.com/nounsDAO/nouns-monorepo/blob/718211e063d511eeda1084710f6a682955e80dcb/packages/nouns-contracts/contracts/governance/NounsDAOV3Proposals.sol#L571-L581

Tools Used

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;

Assessed type

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter