code4rena-2024-03-dittoeth-m06

[M-06] ShortOrders can be created with ercAmount == minAskEth/2, increasing the gas costs for matching large orders and disincentivizing liquidators from liquidating them

보고서

Summary

최소 판매량의 50% 짜리 판매량을 가진 숏 주문을 만들 수 있다. 소액 주문을 여러개 만들면 구매 주문 체결 시 가스비용이 증가하고, 포지션이 작기 때문에 청산자가 청산하고 싶지 않아하게 된다. 이는 불량 채권이 생기게 유도한다.

Keyword

lack of input validation, defi

Vulnerability

두가지 특성/취약점을 이용하여 공격자가 ercAmount(판매량)가 minAskEth/2 이고(최소 판매량의 50%), 부채가 0인 부분매칭 숏 주문을 생성할 수 있다.

먼저 판매량이 minAskEth/2인 숏 주문을 만들어내는 방법을 알아보자.

구매 요청을 할 때, bidMatchAlgo 함수에서 기존 판매/숏 주문과 매칭한다. bidMatchAlgo 함수에서 주문을 체결할 때, 남은 구매 주문량이 숏의 판매량보다 적어 판매/숏 주문이 부분 체결되었다면 남은 양을 조사한다. 판매/숏 주문의 남은 판매량의 가치가 lowestSell.ercAmount.mul(lowestSell.price) >= LibAsset.minAskEth(asset).mul(C.DUST_FACTOR) 로 최소 주문량의 C.DUST_FACTOR 비율보다 적다면 남은 주문은 닫고, 이보다 크다면 닫지 않는다.

C.DUST_FACTOR 는 상수로 0.5 ether 로 50%를 의미한다. 판매/숏 주문이 부분매칭 되었을 때 남아있는 판매량이 최소 판매주문량 minAskEth의 50% 이상이면 주문이 닫히지 않고, 그보다 적게 남아있을 시 주문을 닫는다. 즉, 이 방법을 통해 소량 판매 주문을 남겨둘 수 있다. (일반적으로 minAskEth 미만의 판매량을 가진 주문은 생성할 수 없다.)

    function bidMatchAlgo(
        address asset,
        STypes.Order memory incomingBid,
        MTypes.OrderHint[] memory orderHintArray,
        MTypes.BidMatchAlgo memory b
    ) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
        uint256 minBidEth = LibAsset.minBidEth(asset);
        MTypes.Match memory matchTotal;
 
        while (true) {
            ...
 
            if (incomingBid.price >= lowestSell.price) {
                // Consider bid filled if only dust amount left
                if (incomingBid.ercAmount.mul(lowestSell.price) == 0) {
                    return matchIncomingBid(asset, incomingBid, matchTotal, b);
                }
                matchlowestSell(asset, lowestSell, incomingBid, matchTotal);
                if (incomingBid.ercAmount > lowestSell.ercAmount) {
                    ...
@>              } else { // 판매/숏 주문이 부분 매칭 시
                    if (incomingBid.ercAmount == lowestSell.ercAmount) {
                        ...
                    } else {
                        l...
                        // Check reduced dust threshold for existing limit orders
@>                      if (lowestSell.ercAmount.mul(lowestSell.price) >= LibAsset.minAskEth(asset).mul(C.DUST_FACTOR)) { // 남은 양이 minAskEth 의 50% 이상일 시 부분 매칭된 주문을 닫지 않음
@>                          b.dustShortId = b.dustAskId = 0;
                        }
                    }
                    incomingBid.ercAmount = 0;
                    return matchIncomingBid(asset, incomingBid, matchTotal, b);
                }
            } else {
                ...
            }
        }
    }

둘째로 부채가 0인 부분매칭 숏 주문을 만들어내는 방법을 알아보자.

exitShortWalletexitShortErcEscrowed를 호출하여 숏을 닫을 때, 이 숏이 부분매칭된 숏이라면 연동된 숏 주문을 닫아야 한다. LibSRUtil.checkShortMinErc 를 호출하여 이를 처리한다. 연동된 숏 주문의 id는 유저가 전달한 파라미터를 사용한다.

@>  function exitShortWallet(address asset, uint8 id, uint88 buybackAmount, uint16 shortOrderId)
        external
        isNotFrozen(asset)
        nonReentrant
        onlyValidShortRecord(asset, msg.sender, id)
    {
        ...
 
        if (buybackAmount == ercDebt) {
            uint88 collateral = short.collateral;
            s.vaultUser[Asset.vault][msg.sender].ethEscrowed += collateral;
            LibSRUtil.disburseCollateral(asset, msg.sender, collateral, short.dethYieldRate, short.updatedAt);
@>          LibShortRecord.deleteShortRecord(asset, msg.sender, id);
        } else {
            short.ercDebt -= buybackAmount;
        }
 
        LibSRUtil.checkShortMinErc({
            asset: asset,
            initialStatus: initialStatus,
@>          shortOrderId: shortOrderId,
            shortRecordId: id,
            shorter: msg.sender
        });
 
        emit Events.ExitShortWallet(asset, msg.sender, id, buybackAmount);
    }

하지만 exitShortWalletexitShortErcEscrowed, 그리고 LibSRUtil.checkShortMinErc 에서는 파라미터로 전달된 연동된 숏 주문 shortOrderId 가 이미 취소된 숏 주문인지를 확인하지 않는다.

숏 주문이 취소되더라도 id가 재활용 리스트에 들어갈 뿐, 데이터는 그대로 남아있다. id를 재활용하는 시점에 데이터를 덮어쓰는 식으로 사용하여 가스를 줄이고 있다. 또한 숏 레코드 id 역시 재활용될 수 있다. 따라서 이미 취소된 숏 주문과 연동된 숏 id를 가진, 새로운 숏이 존재할 수 있다. 잘못된 shortOrderId 를 입력해도 shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter 를 우회할 수 있다는 의미이다.

@>  function checkShortMinErc(address asset, SR initialStatus, uint16 shortOrderId, uint8 shortRecordId, address shorter)
        internal
        returns (bool isCancelled)
    {
        AppStorage storage s = appStorage();
 
        STypes.ShortRecord storage shortRecord = s.shortRecords[asset][shorter][shortRecordId];
        uint256 minShortErc = LibAsset.minShortErc(asset);
 
@>      if (initialStatus == SR.PartialFill) {
            // Verify shortOrder
            STypes.Order storage shortOrder = s.shorts[asset][shortOrderId];
@>          if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
 
            if (shortRecord.status == SR.Closed) {
                // Check remaining shortOrder
@>              if (shortOrder.ercAmount < minShortErc) {
                    // @dev The resulting SR will not have PartialFill status after cancel
                    LibOrders.cancelShort(asset, shortOrderId);
                    isCancelled = true;
                }
            } else {
                // Check remaining shortOrder and remaining shortRecord
                if (shortOrder.ercAmount + shortRecord.ercDebt < minShortErc) revert Errors.CannotLeaveDustAmount();
            }
        } else if (shortRecord.status != SR.Closed && shortRecord.ercDebt < minShortErc) {
            revert Errors.CannotLeaveDustAmount();
        }
    }

숏이 가진 부채를 모두 상환하면 숏 레코드는 삭제된다. 이는 즉 Closed 상태로 초기화되고, 부채는 0이 된다. 하지만 잘못된 숏 주문 shortOrderId를 입력했기 때문에 실제로 이 숏과 연동된 숏 주문은 닫히지 않는다. 즉, 이 숏 주문은 부채가 0이 된다.

다음 시나리오를 생각해보자.

  • 주소 A로 숏 주문을 생성하고 주소 B로 다른 숏 주문을 생성한다. (숏 주문 생성시 숏 레코드도 함께 생성된다.)
    • 주소 A 숏 레코드 id: 1
    • 주소 A 숏 주문 id: 101
    • 주소 B 숏 레코드 id: 1 (유저별로 숏 레코드 id를 관리함. A의 숏 레코드 1과 B의 1은 별개라는 의미)
    • 주소 B 숏 주문 id: 100
  • 주소 A의 숏 주문을 취소한 뒤 주소 B의 주문을 취소한다. 주소 B의 주문 id가 먼저 재활용되기 위함이므로 순서가 중요하다.
    • A 숏 레코드 id 재활용 리스트: [1]
    • 주문 id 재활용 리스트: [100, 101] (100이 먼저 사용됨)
    • 주문이 취소되어도 데이터는 최적화를 위해 삭제하지 않고 남아있다. id를 재활용하는 시점에 덮어쓰는 식으로 사용하여 가스를 줄이고 있다.
  • 주소 A의 숏 주문(3000 dUSD를 판매)을 생성한다. 이 숏 주문은 주소 B가 사용했던 주문 id를 재활용한다.
    • 새로운 A 숏 레코드 id: 1
    • 새로운 A 숏 주문 id: 100
    • 주문 id 재활용 리스트: [101]
      • 주문 id 101은 아직 재활용되지 않았으므로 이전 데이터(처음에 A가 생성한 숏 주문)를 가지고 있다. 연동된 숏 id 는 1, 숏 주문의 주인은 A임을 저장하고 있다.
  • 3000 - minAskEth/2 만큼의 구매 주문을 생성하여 새로운 A의 숏과 체결된다.
    • A의 숏 주문은 부분매칭되어 minAskEth/2 만큼의 판매량(ercAmount)이 남는다.
  • exitShortWallet 를 호출하여 숏 종료를 요청한다.
    • shortOrderId 파라미터로 101 을 넘긴다. (잘못된 주문 id, 이미 취소되었지만 아직 재활용되지 않아 데이터가 남아있는 주문 id)
    • buybackAmount 파라미터는 3000 - minAskEth/2 로 하여 체결된 dUSD를 모두 지불한다. 숏은 부채를 모두 갚고 Closed 상태가 된다.
  • LibSRUtil.checkShortMinErc 에서 숏 주문을 취소 처리를 하려고 한다.
    • shortOrderId 는 실제 숏 주문의 id가 아닌 이미 닫힌 숏 id이고, shortOrder.ercAmount < minShortErc 조건이 false 이다.
    • 따라서 LibOrders.cancelShort 가 호출되지 않은 채 종료한다.
    • 실제로 취소되어야 하는 id 100 주문은 닫히지 않는다.

Impact

소량을 판매하는 숏 주문을 많이 만들면 유저가 토큰을 구매할 때 많은 주문과 매칭되어야 하므로 가스 비용이 증가한다. 또한 포지션이 작은 숏은 청산을 하더라도 청산자가 별로 이익을 보지 못하므로 청산하지 않으려고 할 것이다.

Mitigation

checkShortMinErc 함수에서 연동된 숏 주문이 이미 취소되었는지 확인한다.

    function checkShortMinErc(address asset, SR initialStatus, uint16 shortOrderId, uint8 shortRecordId, address shorter)
        internal
        returns (bool isCancelled)
    {
        AppStorage storage s = appStorage();
 
        STypes.ShortRecord storage shortRecord = s.shortRecords[asset][shorter][shortRecordId];
        uint256 minShortErc = LibAsset.minShortErc(asset);
 
        if (initialStatus == SR.PartialFill) {
            // Verify shortOrder
            STypes.Order storage shortOrder = s.shorts[asset][shortOrderId];
-           if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter) revert Errors.InvalidShortOrder();
+           if (shortOrder.shortRecordId != shortRecordId || shortOrder.addr != shorter || shortOrder.orderType == O.Cancelled) revert Errors.InvalidShortOrder();
 
            ...
        } else if (shortRecord.status != SR.Closed && shortRecord.ercDebt < minShortErc) {
            revert Errors.CannotLeaveDustAmount();
        }
    }

Memo

종료된 숏 레코드 id가 재활용 리스트에 들어가있다는 점도 문제로 보인다. 주문이 체결되었을 때, Closed 된 숏 레코드가 Fullyfilled 상태로 초기화되겠지만, 여전히 재활용 리스트에 있을 것 같다.


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