sherlock-2023-11-nouns-builder-m02

[M-02] MerkleReserveMinter minting methodology is incompatible with current governance structure and can lead to migrated DAOs being hijacked immediately

보고서

Summary

MerkleReserveMinter는 대량의 토큰을 즉시 발행할 수 있어, 토큰이 개별적으로 경매를 통해 발행되어 표가 조금씩 증가하는 현재의 거버넌스 구조와 호환되지 않는다. 동일한 블록에서 제안을 발행하고 생성함으로써 사용자는 실제보다 낮은 정족수로 제안을 생성할 수 있다. 이를 통해 마이그레이션된 DAO를 더 쉽게 탈취할 수 있다.

Keyword

dao, 51% attack

Vulnerability

MerkleReserveMinter 에서 민팅할 때, 유저는 단일 트랜잭션에서 원하는 만큼(자신에게 할당된 내에서) 토큰을 발행받을 수 있다. 이는 단일 트랜잭션에서 토큰 공급이 급격하게 증가할 수 있음을 의미한다.

function mintFromReserve(address tokenContract, MerkleClaim[] calldata claims) public payable {
    uint256 claimCount = claims.length;
    ...
 
    unchecked {
    @>  for (uint256 i = 0; i < claimCount; ++i) {
            // Load claim in memory
            MerkleClaim memory claim = claims[I];
 
            // Requires one proof per tokenId to handle cases where users want to partially claim
            if (!MerkleProof.verify(claim.merkleProof, settings.merkleRoot, keccak256(abi.encode(claim.mintTo, claim.tokenId)))) {
                revert INVALID_MERKLE_PROOF(claim.mintTo, claim.merkleProof, settings.merkleRoot);
            }
 
            // Only allowing reserved tokens to be minted for this strategy
    @>      IToken(tokenContract).mintFromReserveTo(claim.mintTo, claim.tokenId);
        }
    }
    ...
}

DAO에 제안을 생성할 때, quorumVotes는 제안 시점까지의 totalSupply의 quorumThresholdBps % 로 설정된다. timeCreated는 현재 block.time으로 설정된다.

    function propose(
        address[] memory _targets,
        uint256[] memory _values,
        bytes[] memory _calldatas,
        string memory _description
    ) external returns (bytes32) {
        ...
 
        // Store the proposal data
        proposal.voteStart = SafeCast.toUint32(snapshot);
        proposal.voteEnd = SafeCast.toUint32(deadline);
        proposal.proposalThreshold = SafeCast.toUint32(currentProposalThreshold);
@>      proposal.quorumVotes = SafeCast.toUint32(quorum());
        proposal.proposer = msg.sender;
@>      proposal.timeCreated = SafeCast.toUint32(block.timestamp);
 
        emit ProposalCreated(proposalId, _targets, _values, _calldatas, _description, descriptionHash, proposal);
 
        return proposalId;
    }
 
function quorum() public view returns (uint256) {
    unchecked {
@>      return (settings.token.totalSupply() * settings.quorumThresholdBps) / BPS_PER_100_PERCENT;
    }
}

투표를 할 때는 제안이 생성된 시점의 표를 이용한다. 즉, 제안을 만든 후 동일 트랜잭션에서 NFT를 발행한다면 투표 자격을 가질 수 있다.

    function _castVote(
        bytes32 _proposalId,
        address _voter,
        uint256 _support,
        string memory _reason
    ) internal returns (uint256) {
        ...
        unchecked {
            // Get the voter's weight at the time the proposal was created
@>          weight = getVotes(_voter, proposal.timeCreated);

낮은 정족수로 제안을 작성한 뒤 동일 트랜잭션에서 토큰을 다량 발행받으면 쉽게 51% 공격을 할 수 있다.

Impact

DAO를 완전히 탈취할 수 있다.

Mitigation

체크포인트 기반의 totalSupply를 사용하도록 변경한다. 정족수는 현재 totalSupply 대신 이를 기반으로 계산한다.

실제로는 모든 예약된 토큰이 발행되었거나 지연 시간이 지난 후에만 제안을 생성할 수 있도록 수정했다.


tags: bughunting, nouns dao, smart contract, solidity, dao, 51% attack, severity medium