DittoETH

Ditto
DeFiFoundryOracle
55,000 USDC
View results
Submission Details
Severity: high
Valid

The liquidation flagger can be prevented from liquidating a short position

Summary

The liquidation flagger can be griefed from liquidating a short within the ~10hrs - ~12hrs liquidation timeline by front-running the liquidation call and re-using the incorrectly expired flagger id by flagging another short.

Vulnerability Details

To liquidate a short position as part of the primary liquidation mechanism, the short must be first flagged via the MarginCallPrimaryFacet.flagShort function. After flagging, the short position owner has some time (by default 10 hours) to bring the collateral ratio up above the primary liquidation margin (LibAsset.primaryLiquidationCR(..)) to prevent the liquidation.

If the collateral ratio is not brought up above the primary liquidation margin within the given time frame, the primary liquidation is executable.

Based on the outlined liquidation timeline, the liquidation flagger is exclusively able to liquidate the short within two hours. This is determined in lines 391-393 of the _canLiquidate function:

File: MarginCallPrimaryFacet.sol
351: function _canLiquidate(MTypes.MarginCallPrimary memory m)
352: private
353: view
354: returns (bool)
355: {
... // [...]
383:
384: uint256 timeDiff = LibOrders.getOffsetTimeHours() - m.short.updatedAt;
385: uint256 resetLiquidationTime = LibAsset.resetLiquidationTime(m.asset);
386:
387: if (timeDiff >= resetLiquidationTime) {
388: return false;
389: } else {
390: uint256 secondLiquidationTime = LibAsset.secondLiquidationTime(m.asset);
391: @> bool isBetweenFirstAndSecondLiquidationTime = timeDiff
392: @> > LibAsset.firstLiquidationTime(m.asset) && timeDiff <= secondLiquidationTime
393: @> && s.flagMapping[m.short.flaggerId] == msg.sender;
394: bool isBetweenSecondAndResetLiquidationTime =
395: timeDiff > secondLiquidationTime && timeDiff <= resetLiquidationTime;
396: if (
397: !(
398: (isBetweenFirstAndSecondLiquidationTime)
399: || (isBetweenSecondAndResetLiquidationTime)
400: )
401: ) {
402: revert Errors.MarginCallIneligibleWindow();
403: }
404:
405: return true;
406: }
407: }

The flagMapping mapping is checked to ensure only the liquidation flagger can call this function. Otherwise, the function will revert with the Errors.MarginCallIneligibleWindow error if called by anyone other than the flagger during the hours ~10hrs - ~12hrs.

Let's examine the LibShortRecord.setFlagger function, called within the MarginCallPrimaryFacet.flagShort function:

The flagger of a short is identified by the flaggerId, which is associated with the caller address (msg.sender) in the flagMapping storage mapping.

Flagger ids are re-usable once a flag is expired, i.e., the time delta between now and the flag's update timestamp (g_updatedAt) is greater than the first liquidation time LibAsset.firstLiquidationTime(..), which is by default 10 hours. See line 394 of the setFlagger function.

377: function setFlagger(
378: STypes.ShortRecord storage short,
379: address cusd,
380: uint16 flaggerHint
381: ) internal {
382: AppStorage storage s = appStorage();
383: STypes.AssetUser storage flagStorage = s.assetUser[cusd][msg.sender];
384:
385: //@dev Whenever a new flagger flags, use the flaggerIdCounter.
386: if (flagStorage.g_flaggerId == 0) {
387: address flaggerToReplace = s.flagMapping[flaggerHint];
388:
389: uint256 timeDiff = flaggerToReplace != address(0)
390: ? LibOrders.getOffsetTimeHours()
391: - s.assetUser[cusd][flaggerToReplace].g_updatedAt
392: : 0;
393: //@dev re-use an inactive flaggerId
394: ❌ if (timeDiff > LibAsset.firstLiquidationTime(cusd)) {
395: delete s.assetUser[cusd][flaggerToReplace].g_flaggerId;
396: short.flaggerId = flagStorage.g_flaggerId = flaggerHint;
397: } else if (s.flaggerIdCounter < type(uint16).max) {
398: //@dev generate brand new flaggerId
399: short.flaggerId = flagStorage.g_flaggerId = s.flaggerIdCounter;
400: s.flaggerIdCounter++;
401: } else {
402: revert Errors.InvalidFlaggerHint();
403: }
404: s.flagMapping[short.flaggerId] = msg.sender;
405: } else {
406: //@dev re-use flaggerId if flagger has an existing one
407: short.flaggerId = flagStorage.g_flaggerId;
408: }
409: short.updatedAt = flagStorage.g_updatedAt = LibOrders.getOffsetTimeHours();
410: }

Once a flag is expired, another liquidator can re-use this same flaggerId by calling the setFlagger function and providing the flaggerHint equal to the expired flagger ID. This will overwrite the flagMapping entry for the expired flagger ID with the new caller address (msg.sender), which is the other liquidator.

However, determining the flagger id expiry by checking if timeDiff > LibAsset.firstLiquidationTime(cusd) in line 394 collides with the liquidation eligibility check in _canLiquidate in lines 391-393, which also checks the timeDiff against LibAsset.firstLiquidationTime(..).

This effectively means that once the liquidation flagger is able to execute the liquidation via the MarginCallPrimaryFacet.liquidate function, due to the isBetweenFirstAndSecondLiquidationTime condition in lines 391-393 evaluating to true, the flagger id is already considered expired and can be re-used by another liquidator. If the other liquidator front-runs, by re-using the same flagger id, the s.flagMapping[m.short.flaggerId] == msg.sender check in line 393 is false, preventing the liquidation.

Impact

Liquidation flagger ids are considered expired too soon and can be re-used by other users, preventing the liquidation of the short position within the first liquidation time frame (i.e., ~10hrs - ~12hrs).

Tools Used

Manual Review

Recommendations

Consider using LibAsset.resetLiquidationTime(..) (i.e., default ~16 hours) in the setFlagger function to determine the expiry of a flagger id instead of the LibAsset.firstLiquidationTime function to avoid the griefing attack.

Updates

Lead Judging Commences

0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Validated
Assigned finding tags:

finding-533

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.