Nouns Builder contest - berndartmueller's results

A permissionless, governed protocol to deploy nouns-style DAOs complete with treasury, generative collections, and governance mechanisms.

General Information

Platform: Code4rena

Start Date: 06/09/2022

Pot Size: $90,000 USDC

Total HM: 33

Participants: 168

Period: 9 days

Judge: GalloDaSballo

Total Solo HM: 10

Id: 157

League: ETH

Nouns Builder

Findings Distribution

Researcher Performance

Rank: 21/168

Findings: 3

Award: $1,077.28

🌟 Selected for report: 2

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: berndartmueller

Also found by: 0x52, 0xSky, Chom, PwnPatrol, bin2chen, cccz, davidbrai, elprofesor, izhuer, m9800, rvierdiiev

Labels

bug
3 (High Risk)
sponsor confirmed

Awards

235.614 USDC - $235.61

External Links

Lines of code

https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/lib/token/ERC721Votes.sol#L252-L253

Vulnerability details

Voting power for each NFT owner is persisted within timestamp-dependent checkpoints. Every voting power increase or decrease is recorded. However, the implementation of ERC721Votes creates separate checkpoints with the same timestamp for each interaction, even when the interactions happen in the same block/timestamp.

Impact

Checkpoints with the same timestamp will cause issues within the ERC721Votes.getPastVotes(..) function and will return incorrect votes for a given _timestamp.

Proof of Concept

lib/token/ERC721Votes.sol#L252-L253

/// @dev Records a checkpoint
/// @param _account The account address
/// @param _id The checkpoint id
/// @param _prevTotalVotes The account's previous voting weight
/// @param _newTotalVotes The account's new voting weight
function _writeCheckpoint(
    address _account,
    uint256 _id,
    uint256 _prevTotalVotes,
    uint256 _newTotalVotes
) private {
    // Get the pointer to store the checkpoint
    Checkpoint storage checkpoint = checkpoints[_account][_id];

    // Record the updated voting weight and current time
    checkpoint.votes = uint192(_newTotalVotes);
    checkpoint.timestamp = uint64(block.timestamp);

    emit DelegateVotesChanged(_account, _prevTotalVotes, _newTotalVotes);
}

Consider the following example and the votes checkpoint snapshots:

Note: Bob owns a smart contract used to interact with the protocol

Transaction 0: Bob's smart contract receives 1 NFT through minting (1 NFT equals 1 vote)

Checkpoint IndexTimestampVotes
001

Transaction 1: Bob's smart contract receives one more NFT through minting

Checkpoint IndexTimestampVotes
001
112

Transaction 1: Within the same transaction 1, Bob's smart-contract delegates 2 votes to Alice

Checkpoint IndexTimestampVotes
001
112
210

Transaction 1: Again within the same transaction 1, Bob's smart contract decides to reverse the delegation and self-delegates

Checkpoint IndexTimestampVotes
001
112
210
312

Transaction 1: Bob's smart contract buys one more NFT

Checkpoint IndexTimestampVotes
001
112
210
312
423

Bob now wants to vote (via his smart contract) on a governance proposal that has been created on timeCreated = 1 (timestamp 1).

Internally, the Governor._castVote function determines the voter's weight by calling getVotes(_voter, proposal.timeCreated).

governance/governor/Governor.sol#L275

weight = getVotes(_voter, proposal.timeCreated);

getVotes calls ERC721.getPastVotes internally:

governance/governor/Governor.sol#L462

function getVotes(address _account, uint256 _timestamp) public view returns (uint256) {
    return settings.token.getPastVotes(_account, _timestamp);
}

ERC721.getPastVotes(..., 1) tries to find the checkpoint within the while loop:

# Iterationlowmiddlehigh
0024

The middle checkpoint with index 2 matches the given timestamp 1 and returns 0 votes. This is incorrect, as Bob has 2 votes. Bob is not able to vote properly.

(Please be aware that this is just one of many examples of how this issue can lead to incorrect vote accounting. In other cases, NFT owners could have more voting power than they are entitled to)

Tools Used

Manual review

Consider batching multiple checkpoints writes per block/timestamp similar to how NounsDAO records checkpoints.

#0 - GalloDaSballo

2022-09-26T13:31:50Z

The Warden has shown how the checkpoint math can be gamed, opening up governance to flashloan exploits, infinite voting power and overall breaking all of governance quorum and execution thresholds.

Because any attacker can spam create checkpoints, to manipulate the result of the Binary Search, they can manipulate their balance to make the Governor think it's way higher than intended.

Mitigation requires ensuring that the only balance recorded for a block is the latest value (end of flashloan so the balance goes back down)

Because the finding breaks accounting, allowing governance takeover, and the invariants of ERC721Votes are broken (votes are not what they are), I agree with High Severity

Findings Information

🌟 Selected for report: zkhorse

Also found by: MEP, Picodes, Solimander, berndartmueller, hxzy, hyh, pcarranzav, pfapostol

Labels

bug
duplicate
3 (High Risk)

Awards

349.0578 USDC - $349.06

External Links

Lines of code

https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/lib/token/ERC721Votes.sol#L181 https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/lib/token/ERC721Votes.sol#L268

Vulnerability details

Minting an NFT entitles the NFT owner to 1 vote. This is accomplished by using the ERC721Votes._afterTokenTransfer ERC721 hook. However, the ERC721Votes._afterTokenTransfer and ERC721Votes._delegate function are not using the correct (and self-delegated) delegators and delegatees (NFTs are self-delegated by default).

Impact

Self-delegating voting power following an NFT mint leaves the NFT owner with double the voting power.

Proof of Concept

The following test case demonstrates how an NFT owner can increase the voting power by self-delegating after a previous NFT mint.

Copy paste the following test case into Gov.t.sol and run the test via forge test --match-test test_doubleYourVotes:

function test_doubleYourVotes() public {
    mintVoter1();

    assertEq(token.balanceOf(voter1), 1);

    vm.warp(block.timestamp + 5 minutes);

    uint256 votesBeforeSelfDelegation = token.getPastVotes(voter1, block.timestamp - 1);

    assertEq(votesBeforeSelfDelegation, 1);

    vm.prank(voter1);
    token.delegate(voter1);

    vm.warp(block.timestamp + 5 minutes);

    uint256 votesAfterSelfDelegation = token.getPastVotes(voter1, block.timestamp - 1);

    assertEq(votesAfterSelfDelegation, 2);
}

Tools Used

Manual review

Consider using delegates(..) within the ERC721Votes._afterTokenTransfer and ERC721Votes._delegate function:

 function _afterTokenTransfer(
    address _from,
    address _to,
    uint256 _tokenId
) internal override {
    // Transfer 1 vote from the sender to the recipient
    _moveDelegateVotes(delegates(_from), delegates(_to), 1);

    super._afterTokenTransfer(_from, _to, _tokenId);
}
function _delegate(address _from, address _to) internal {
    // Get the previous delegate
    address prevDelegate = delegates(_from);

    // Store the new delegate
    delegation[_from] = _to;

    emit DelegateChanged(_from, prevDelegate, _to);

    // Transfer voting weight from the previous delegate to the new delegate
    _moveDelegateVotes(prevDelegate, _to, balanceOf(_from));
}

Note: This requires changing the function visibility of ERC721Votes.delegates from external to public.

Attention! This recommended mitigation opens up a new issue. Delegating votes to the zero-address will break token transfers. Therefore it's advised to prevent vote delegation to address(0) via ERC721Votes.delegate and ERC721Votes.delegateBySig.

#0 - GalloDaSballo

2022-09-26T13:36:16Z

Dup of #413

Findings Information

🌟 Selected for report: berndartmueller

Also found by: CertoraInc, m9800, scaraven

Labels

bug
2 (Med Risk)
sponsor confirmed

Awards

492.6103 USDC - $492.61

External Links

Lines of code

https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/governance/governor/Governor.sol#L151 https://github.com/code-423n4/2022-09-nouns-builder/blob/7e9fddbbacdd7d7812e912a369cfd862ee67dc03/src/governance/governor/Governor.sol#L353-L377

Vulnerability details

When creating a new governance proposal, the proposalId is generated by hashing the proposal data (_targets, _values, _calldatas, descriptionHash). To prevent duplicated proposals, the current Governor implementation checks if the proposalId exists already. If it exists, the call will revert with the PROPOSAL_EXISTS error.

Impact

Anyone can prevent others from creating governance proposals by front-running the create proposal transaction with the same data, followed by an immediate call to the Governor.cancel function.

This will prevent creating a proposal with the same proposal data. A proposal creator would have to slightly change the proposal to try to create it again (however, it can be prevented again due to the aforementioned issue)

Proof of Concept

governance/governor/Governor.propose

function propose(
    address[] memory _targets,
    uint256[] memory _values,
    bytes[] memory _calldatas,
    string memory _description
) external returns (bytes32) {
    [..]

    // Compute the description hash
    bytes32 descriptionHash = keccak256(bytes(_description));

    // Compute the proposal id
    bytes32 proposalId = hashProposal(_targets, _values, _calldatas, descriptionHash);

    // Get the pointer to store the proposal
    Proposal storage proposal = proposals[proposalId];

    // Ensure the proposal doesn't already exist
    if (proposal.voteStart != 0) revert PROPOSAL_EXISTS(proposalId); // @audit-info Reverts in case the proposals with the same data exists already

    [..]
}

governance/governor/Governor.cancel

Cancelling a proposal updates the proposal.canceled boolean property to true. proposal.voteStart is left unchanged (!= 0).

/// @notice Cancels a proposal
/// @param _proposalId The proposal id
function cancel(bytes32 _proposalId) external {
    // Ensure the proposal hasn't been executed
    if (state(_proposalId) == ProposalState.Executed) revert PROPOSAL_ALREADY_EXECUTED();

    // Get a copy of the proposal
    Proposal memory proposal = proposals[_proposalId];

    // Cannot realistically underflow and `getVotes` would revert
    unchecked {
        // Ensure the caller is the proposer or the proposer's voting weight has dropped below the proposal threshold
        if (msg.sender != proposal.proposer && getVotes(proposal.proposer, block.timestamp - 1) > proposal.proposalThreshold)
            revert INVALID_CANCEL();
    }

    // Update the proposal as canceled
    proposals[_proposalId].canceled = true;

    // If the proposal was queued:
    if (settings.treasury.isQueued(_proposalId)) {
        // Cancel the proposal
        settings.treasury.cancel(_proposalId);
    }

    emit ProposalCanceled(_proposalId);
}

Tools Used

Manual review

Consider adding a per-account nonce storage variable (e.g. mapping(address => uint256) internal proposalCreatorNonces; to the Governor contract and include the proposalCreatorNonces[msg.sender]++ nonce within the computed proposal id.

#0 - GalloDaSballo

2022-09-26T00:39:52Z

The Waren has shown how, a motivated attacker can consistently grief the DAO with multiple spam proposals, that front-run the "official" ones, with the goal of cancelling them and preventing them to go through.

This exploit can be performed as long as the attacker has sufficient balance to be a Proposer

The only saving grace from this finding being a High Severity is that there are ways to avoid the front-running.

Specifically a proposer could:

  • Use a private relayer
  • Ask a miner / proposer to mine the tx

Additionally, the Proposer could use a contract, which would loop through proposals, by using a source of randomness to try a bunch of new proposals.

Because checking if a proposal exists is cheaper than writing it (2.1k vs 20k to write in a storage slot), the write can eventually come up with one proposal the attacker didn't perform.

However the remediation requires a new contract, not available at this time.

The reason why this remediation is possible is that the _description parameter is used to uniquely identify a proposal, this allows to create gibberish strings until we get one that goes through.

Because the attack is:

  • Possible
  • Easy to implement But can be sidestepped, via a fairly complicated process

I think Medium Severity to be appropriate.

Remediation would be to add the proposer address as part of the proposal id identifier

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