code4rena-2024-03-dittoeth-m08

[M-08] If a redemption has N disputable shorts, it is possible to dispute N-1 times the redemption to maximize the penalty

보고서

Summary

이의제기를 할 때, 담보율을 확인할 인덱스를 유저가 제공한다. 인덱스에 따라 이의제기를 쪼개서 할 수 있는데, 쪼개서 했을 때와 처음부터 정확한 인덱스에 했을 때의 패널티 양이 다르다.

Keyword

logic flaw, lack of input validation

Vulnerability

청산 요청을 했을 때 청산 대상이지만 청산 요청 되지 않은 숏이 있다면 disputeRedemption 를 호출하여 이의를 제기할 수 있다. 반대에 성공하면 이의제기에 사용된 숏보다 담보율이 높은 숏에 대한 청산 요청은 해제되고, 청산 요청자는 패널티를 받게 된다. 이의제기자는 패널티 만큼을 콜 수수료로 받아간다.

패널티 비율은 penaltyPct = min(max(callerFeePct, (currentProposal.CR - disputeCR) / currentProposal.CR), 0.33 ether) 로 계산된다. 즉 최대 33%, 최소 설정값(callerFeePct, 여기서는 0.5% 라 하자) 사이이고, 청산 대상 중 담보율이 가장 높은 숏과 이의제기 숏의 담보율 차이가 높을 수록 높아진다. 패널티의 양은 penaltyAmt = incorrectErcDebt * penaltyPct 로 청산 요청이 해제되는 숏의 dUSD의 penaltyPct % 만큼을 받는다.

disputeRedemptionincorrectIndex 인덱스부터 시작하여 청산 요청 배열을 확인한다. 문제는 예를들어 3번 인덱스부터 담보율이 더 높을 때, 3번 인덱스보다 높은 인덱스부터 이의를 제기하여 여러번에 걸쳐 disputeRedemption 를 호출할 때 발생한다.

    function disputeRedemption(address asset, address redeemer, uint8 incorrectIndex, address disputeShorter, uint8 disputeShortId)
        external
        isNotFrozen(asset)
        nonReentrant
    {
        ...
 
@>      MTypes.ProposalData memory incorrectProposal = decodedProposalData[incorrectIndex];
        MTypes.ProposalData memory currentProposal;
        STypes.Asset storage Asset = s.asset[d.asset];
 
        uint256 disputeCR = disputeSR.getCollateralRatio(redeemerAssetUser.oraclePrice);
 
@>      if (disputeCR < incorrectProposal.CR && disputeSR.updatedAt + C.DISPUTE_REDEMPTION_BUFFER <= redeemerAssetUser.timeProposed)
        {
            // @dev All proposals from the incorrectIndex onward will be removed
            // @dev Thus the proposer can only redeem a portion of their original slate
@>          for (uint256 i = incorrectIndex; i < decodedProposalData.length; i++) {
@>              currentProposal = decodedProposalData[i];
 
                STypes.ShortRecord storage currentSR = s.shortRecords[d.asset][currentProposal.shorter][currentProposal.shortId];
                currentSR.collateral += currentProposal.colRedeemed;
                currentSR.ercDebt += currentProposal.ercDebtRedeemed;
 
@>              d.incorrectCollateral += currentProposal.colRedeemed;
                d.incorrectErcDebt += currentProposal.ercDebtRedeemed;
            }
 
            ...
 
            // @dev Penalty is based on the proposal with highest CR (decodedProposalData is sorted)
            // @dev PenaltyPct is bound between CallerFeePct and 33% to prevent exploiting primaryLiquidation fees
@>          uint256 penaltyPct = LibOrders.min(
                LibOrders.max(LibAsset.callerFeePct(d.asset), (currentProposal.CR - disputeCR).div(currentProposal.CR)), 0.33 ether
            );
 
@>          uint88 penaltyAmt = d.incorrectErcDebt.mulU88(penaltyPct);
 
            // @dev Give redeemer back ercEscrowed that is no longer used to redeem (penalty applied)
@>          redeemerAssetUser.ercEscrowed += (d.incorrectErcDebt - penaltyAmt);
@>          s.assetUser[d.asset][msg.sender].ercEscrowed += penaltyAmt;
        } else {
            revert Errors.InvalidRedemptionDispute();
        }
    }

다음 상황을 생각해보자.

  • 4개의 숏이 있고, 담보율(CR)은 CR1 < CR2 < CR3 < CR4 이다.
    • CR1 = 1.1, CR2 = 1.2, CR3 = 1.3, CR4 = 1.4
    • 모두 ercDebt는 1ETH 이라고 하자.
  • 청산 요청자가 [Short2, Short3, Short4] 를 청산 요청했다.
    • Short1은 이들 모두보다 담보율이 낮다.
  • CR1 < CR2 이기 때문에 청산 요청된 첫번째 숏부터 틀렸다. 하지만 공격자(이의제기자)는 인덱스 2 부터 이의제기를 0, 1, 2 총 3번 disputeRedemption 를 호출할 수 있다.

처음부터 0번 인덱스를 이의제기에 사용했다면 penaltyPct = min( max(0.005, (1.4 - 1.1)/1.4 ), 0.33) = min( max(0.005, 0.214), 0.33) = 0.214 이고 (1 + 1 + 1)ETH * 0.214 = 0.642 ETH 만큼이 패널티가 된다.

3번에 걸쳐 이의제기를 했다면 다음과 같다.

  • penaltyPct = min( max(0.005, (1.4 - 1.1)/1.4), 0.33) = min( max(0.005, 0.214), 0.33) = 0.214
    • penaltyAmt = 1 ETH * 0.214 = 0.214 ETH
  • penaltyPct = min( max(0.005, (1.3 - 1.1)/1.3 ), 0.33) = min( max(0.005, 0.153), 0.33) = 0.153
    • penaltyAmt = 1 ETH * 0.153 = 0.153 ETH
  • penaltyPct = min( max(0.005, (1.2 - 1.1)/1.2 ), 0.33) = min( max(0.005, 0.083), 0.33) = 0.083
    • penaltyAmt = 1 ETH * 0.083 = 0.083 ETH
  • 패널티의 총 합은 0.45 ETH로, 0번 인덱스로 바로 이의제기 했을 때보다 작다.

Impact

이의제기를 쪼개서 했을 때와 처음부터 정확한 인덱스에 했을 때의 패널티 양이 다르다.

Mitigation

incorrectIndex 가 n 일 때, n-1 인덱스의 담보율이 이의제기 숏의 담보율보다 낮은지 확인한다.

Memo

원본 보고서는 예시에서 계산을 잘못했다. 처음부터 잘 호출했을 때(0번 인덱스로 바로 이의제기시) 패널티가 더 높다. 따라서 공격자(이의제기자)가 이득을 보지 못한다. 청산 요청자가 잘못됨을 깨닫고 자신의 패널티를 줄이기 위해 이런 짓을 한다는 상황도 올바르지 않다. 그냥 청산자의 다른 계정을 이용하면 패널티를 콜 수수료로 패널티만큼을 받기 때문이다.

다만 쪼개서 했을 때와 그냥 했을 때의 패널티에 차이가 있다는 점은 버그라고 할 만 하다.


tags: bughunting, dittoeth, smart contract, solidity, logic flaw, lack-of-input-validation-vul, severity medium