sherlock-2023-11-nouns-builder-h02

[H-02] Adversary can permanently brick auctions due to precision error in Auction._computeTotalRewards

보고서

Summary

정밀도 손실로 인해 totalRewards와 실제 리워드의 합이 일치하지 않는 상황이 발생할 수 있다. ProtocolRewards.depositRewards 에서는 이 두 값이 일치하지 않으면 트랜잭션을 취소시키며 이로 인해 영원히 경매를 낙찰/다음 경매를 진행할 수 없게 된다. 경매 기능이 영원히 중단된다.

Keyword

dos, arithmetic error, loss of precision

Vulnerability

경매 낙찰금은 추천자, DAO 창립자, Nouns builder 측에게 리워드로 주고, 남은 금액은 Treasury에 예치하여 DAO 운영 비용으로 사용한다.

_computeTotalRewards 에서 각자에게 분배할 리워드를 계산하고, ProtocolRewards.depositRewards 를 호출하여 ProtocolRewards 컨트랙트에 리워드 토큰(ETH)과 리워드 정보를 등록한다.

  function _settleAuction() private {
    ...
 
    // If a bid was placed:
    if (_auction.highestBidder != address(0)) {
        // Cache the amount of the highest bid
        uint256 highestBid = _auction.highestBid;
 
        // If the highest bid included ETH: Pay rewards and transfer remaining amount to the DAO treasury
        if (highestBid != 0) {
            // Calculate rewards
@>          RewardSplits memory split = _computeTotalRewards(currentBidReferral, highestBid, founderReward.percentBps);
 
            if (split.totalRewards != 0) {
                // Deposit rewards
@>              rewardsManager.depositBatch{ value: split.totalRewards }(split.recipients, split.amounts, split.reasons, "");
            }
 
            // Deposit remaining amount to treasury
            _handleOutgoingTransfer(settings.treasury, highestBid - split.totalRewards);
        }

ProtocolRewards 컨트랙트는 오딧팅 스코프가 아니고, 또한 오딧팅 레포지토리에는 MockProtocolRewards만 있다(실제 코드와 비슷하긴 하다). 하지만 Nouns DAO의 공개된 레포지토리에서 실제 코드를 찾을 수 있다. ProtocolRewards.depositRewards 에서는 각자에게 분배하는 토큰의 합이 msg.value와 같지 않다면 트랜잭션을 취소시킨다.

  function depositBatch(address[] calldata recipients, uint256[] calldata amounts, bytes4[] calldata reasons, string calldata comment) external payable {
    uint256 numRecipients = recipients.length;
 
    if (numRecipients != amounts.length || numRecipients != reasons.length) {
      revert ARRAY_LENGTH_MISMATCH();
    }
 
    uint256 expectedTotalValue;
 
    for (uint256 i; i < numRecipients; ) {
      expectedTotalValue += amounts[i];
 
      unchecked {
        ++i;
      }
    }
 
@>  if (msg.value != expectedTotalValue) {
      revert INVALID_DEPOSIT();
    }
    ...

따라서 _computeTotalRewards 함수에서 정밀도 손실이 발생한다면 단 1 wei 차이로도 ProtocolRewards.depositRewards 함수가 실패하고, 경매 정산이 안 되면 다음 경매로 넘어갈 수 없어 경매 기능이 영구적으로 중단된다.

_computeTotalRewards는 각 수신자에게 분배된 퍼센테이지만큼을 계산하여 할당한다. 백분율의 총 합만큼을 곱한 (_finalBidAmount * totalBPS) / BPS_PER_100_PERCENTtotalRewards로 계산한다. 따라서 나눗셈 계산 중 정밀도 손실이 발생할 수 있다.

  function _computeTotalRewards(
    address _currentBidRefferal,
    uint256 _finalBidAmount,
    uint256 _founderRewardBps
  ) internal view returns (RewardSplits memory split) {
    // Get global builder recipient from manager
    address builderRecipient = manager.builderRewardsRecipient();
 
    // Calculate the total rewards percentage
    uint256 totalBPS = _founderRewardBps + referralRewardsBPS + builderRewardsBPS;
 
    // Verify percentage is not more than 100
    if (totalBPS >= BPS_PER_100_PERCENT) {
        revert INVALID_REWARD_TOTAL();
    }
 
    // Calulate total rewards
@>  split.totalRewards = (_finalBidAmount * totalBPS) / BPS_PER_100_PERCENT;
 
    // Check if founder reward is enabled
    bool hasFounderReward = _founderRewardBps > 0 && founderReward.recipient != address(0);
 
    // Set array size based on if founder reward is enabled
    uint256 arraySize = hasFounderReward ? 3 : 2;
 
    // Initialize arrays
    split.recipients = new address[](arraySize);
    split.amounts = new uint256[](arraySize);
    split.reasons = new bytes4[](arraySize);
 
    // Set builder reward
    split.recipients[0] = builderRecipient;
@>  split.amounts[0] = (_finalBidAmount * builderRewardsBPS) / BPS_PER_100_PERCENT;
 
    // Set referral reward
    split.recipients[1] = _currentBidRefferal != address(0) ? _currentBidRefferal : builderRecipient;
@>  split.amounts[1] = (_finalBidAmount * referralRewardsBPS) / BPS_PER_100_PERCENT;
 
    // Set founder reward if enabled
    if (hasFounderReward) {
      split.recipients[2] = founderReward.recipient;
@>    split.amounts[2] = (_finalBidAmount * _founderRewardBps) / BPS_PER_100_PERCENT;
    }
  }

추천 보상이 5%(500)이고 빌더 보상이 5%(500)로 총 10%(1000)라고 가정해보자. 공격자는 컨트랙트를 중단시키기 위해 특정 마지막 숫자로 입찰가를 조작할 수 있다. 끝자리가 19로 끝나는 입찰을 예로 들어보면 totalRewards는 1이지만 각자에게 분배된 리워드는 0으로 정밀도 손실이 발생한다.

split.totalRewards = (19 * 1,000) / 100,000 = 190,000 / 100,000 = 1
 
split.amounts[0] = (19 * 500) / 100,000 = 95,000 / 100,000 = 0
split.amounts[1] = (19 * 500) / 100,000 = 95,000 / 100,000 = 0

Impact

경매 기능이 영구히 중단된다.

Mitigation

totalRewards를 백분율의 합계로 계산하는 대신 계산된 각 수수료의 합으로 계산한다. 이렇게 하면 어떤 경우에도 항상 일치한다.


tags: bughunting, nouns dao, smart contract, solidity, dos, arithmetic error, loss of precision, severity high