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
- libraries/LibSRUtil.sol#L84
- facets/BidOrdersFacet.sol#L155-L197
- facets/BidOrdersFacet.sol#L292-L296
- facets/ExitShortFacet.sol#L67-L73
두가지 특성/취약점을 이용하여 공격자가 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인 부분매칭 숏 주문을 만들어내는 방법을 알아보자.
exitShortWallet 나 exitShortErcEscrowed를 호출하여 숏을 닫을 때, 이 숏이 부분매칭된 숏이라면 연동된 숏 주문을 닫아야 한다. 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);
}하지만 exitShortWallet 나 exitShortErcEscrowed, 그리고 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 숏 레코드 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)이 남는다.
- A의 숏 주문은 부분매칭되어
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