DittoETH

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

Hijacking of flaggerId for liquidating shorts

Summary

The MarginCallPrimaryFacet::flagShort() is vulnerable to hijacking attacks. This vulnerability enables an attacker to hijack the right (flaggerId) to liquidate Short positions flagged by a victim (hijacked flagger).

The attacker takes no risks (losing gas) for flagging any Short themselves, and no paying any gas fee or waiting delay (firstLiquidationTime) is required.

The victim will no longer be eligible to liquidate their flagged Short positions, losing gas, time, and the opportunity to get rewards for free.

Vulnerability Details

When executing the flagShort(), a flagger can provide the flaggerHint variable containing an inactive flaggerId that the flagger wants to replace. This is a recycling mechanism for inactive flaggerIds of the Ditto protocol. The flagShort() will execute the LibShortRecord::setFlagger() to handle tasks regarding the setting of the flagger and re-use of the inactive flaggerId (flaggerHint).

However, the recycling mechanism of inactive flaggerIds is vulnerable to hijacking attacks.

An attacker can create a brand new account (with no cost) or use the existing account if its g_flaggerId state is 0 in order to force the setFlagger() to initiate the recycling mechanism on the target flaggerId (flaggerHint) they want to hijack. Assuming that the victim (target flagger) has been flagged 10 Short positions and is waiting for the firstLiquidationTime delay for liquidating them.

The attacker can front-run the victim to hijack the target flaggerId due to the condition: "timeDiff > LibAsset.firstLiquidationTime(cusd)" because immediately after the firstLiquidationTime delay has passed, the setFlagger() will reset the g_flaggerId of the victim to 0. Then, the target flaggerId (flaggerHint) will be re-assigned to the attacker's g_flaggerId instead.

Finally, the attacker can replace themselves to become a possessor of the hijacked flaggerId (now the short.flaggerId == flaggerHint).

// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/facets/MarginCallPrimaryFacet.sol
function flagShort(address asset, address shorter, uint8 id, uint16 flaggerHint)
external
isNotFrozen(asset)
nonReentrant
onlyValidShortRecord(asset, shorter, id)
{
...
@> short.setFlagger(cusd, flaggerHint); //@audit -- The flagShort() executes LibShortRecord::setFlagger()
emit Events.FlagShort(asset, shorter, id, msg.sender, adjustedTimestamp);
}
// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/libraries/LibShortRecord.sol
function setFlagger(
STypes.ShortRecord storage short,
address cusd,
uint16 flaggerHint
) internal {
AppStorage storage s = appStorage();
STypes.AssetUser storage flagStorage = s.assetUser[cusd][msg.sender];
//@dev Whenever a new flagger flags, use the flaggerIdCounter.
@> if (flagStorage.g_flaggerId == 0) { //@audit -- If the attacker's g_flaggerId == 0, the setFlagger() will either assign an inactive flaggerId (flaggerHint) or a brand new flaggerId to the attacker
address flaggerToReplace = s.flagMapping[flaggerHint];
uint256 timeDiff = flaggerToReplace != address(0)
? LibOrders.getOffsetTimeHours()
- s.assetUser[cusd][flaggerToReplace].g_updatedAt
: 0;
//@dev re-use an inactive flaggerId
@> if (timeDiff > LibAsset.firstLiquidationTime(cusd)) { //@audit -- The attacker can hijack the flaggerId (i.e., flaggerHint) of the target flagger due to the condition: "timeDiff > LibAsset.firstLiquidationTime(cusd)"
@> delete s.assetUser[cusd][flaggerToReplace].g_flaggerId;
@> short.flaggerId = flagStorage.g_flaggerId = flaggerHint;
} else if (s.flaggerIdCounter < type(uint16).max) {
//@dev generate brand new flaggerId
short.flaggerId = flagStorage.g_flaggerId = s.flaggerIdCounter;
s.flaggerIdCounter++;
} else {
revert Errors.InvalidFlaggerHint();
}
@> s.flagMapping[short.flaggerId] = msg.sender; //@audit -- The attacker replaces themselves to become a possessor of the stolen flaggerId (short.flaggerId == flaggerHint)
} else {
//@dev re-use flaggerId if flagger has an existing one
short.flaggerId = flagStorage.g_flaggerId;
}
short.updatedAt = flagStorage.g_updatedAt = LibOrders.getOffsetTimeHours();
}
  • The flagShort() executes LibShortRecord::setFlagger(): https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L72

  • If the attacker's g_flaggerId == 0, the setFlagger() will either assign an inactive flaggerId (flaggerHint) or a brand new flaggerId to the attacker: https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/libraries/LibShortRecord.sol#L386

  • The attacker can hijack the flaggerId (i.e., flaggerHint) of the target flagger due to the condition: "timeDiff > LibAsset.firstLiquidationTime(cusd)": https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/libraries/LibShortRecord.sol#L394-L396

  • The attacker replaces themselves to become a possessor of the stolen flaggerId (short.flaggerId == flaggerHint): https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/libraries/LibShortRecord.sol#L404

After successfully hijacking the target flaggerId, the attacker can call the MarginCallPrimaryFacet::liquidate() to liquidate all eligible Short positions previously flagged by the victim (hijacked flagger).

The attacker requires no waiting period for the firstLiquidationTime delay or even paying any gas fee for flagging those Short positions. In this step, the MarginCallPrimaryFacet::_canLiquidate() will be triggered to verify the liquidation eligibility of the attacker.

Since the attacker would already become the possessor of the hijacked flaggerId, they are now eligible to liquidate all eligible Short positions previously flagged by the victim for free.

// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/facets/MarginCallPrimaryFacet.sol
function liquidate(
address asset,
address shorter,
uint8 id,
uint16[] memory shortHintArray
)
...
{
...
// check if within margin call time window
@> if (!_canLiquidate(m)) { //@audit -- After successfully hijacking the target flaggerId, the attacker can call the liquidate() to liquidate all eligible Short positions that have previously been flagged by the victim (the hijacked flagger) without the need to wait for the firstLiquidationTime delay or even pay any gas fee for flagging them. In this step, the _canLiquidate() is triggered to verify the liquidation eligibility of the attacker
STypes.ShortRecord storage shortRecord = s.shortRecords[asset][shorter][id];
shortRecord.resetFlag();
return (0, 0);
}
_performForcedBid(m, shortHintArray);
_marginFeeHandler(m);
_fullorPartialLiquidation(m);
emit Events.Liquidate(asset, shorter, id, msg.sender, m.ercDebtMatched);
return (m.gasFee, m.ethFilled);
}
// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/facets/MarginCallPrimaryFacet.sol
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; //@audit -- Since the attacker would become the possessor of the stolen flaggerId, the attacker is now eligible to liquidate all eligible Short positions flagged by the victim
bool isBetweenSecondAndResetLiquidationTime =
timeDiff > secondLiquidationTime && timeDiff <= resetLiquidationTime;
if (
!(
(isBetweenFirstAndSecondLiquidationTime)
|| (isBetweenSecondAndResetLiquidationTime)
)
) {
revert Errors.MarginCallIneligibleWindow();
}
return true;
}
}
  • After successfully hijacking the target flaggerId, the attacker can call the liquidate() to liquidate all eligible Short positions that have previously been flagged by the victim (the hijacked flagger) without the need to wait for the firstLiquidationTime delay or even pay any gas fee for flagging them. In this step, the _canLiquidate() is triggered to verify the liquidation eligibility of the attacker: https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L118

  • Since the attacker would become the possessor of the stolen flaggerId, the attacker is now eligible to liquidate all eligible Short positions flagged by the victim: https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L393

Impact

This vulnerability enables the attacker to hijack the right (flaggerId) to liquidate Short positions flagged by the victim. The attacker takes no risks (losing gas) for flagging any Short themselves, and no paying any gas fee or waiting delay (firstLiquidationTime) is required.

The victim (hijacked flagger) will no longer be eligible to liquidate their flagged Short positions, losing gas, time, and the opportunity to get rewards for free.

This vulnerability can affect any flagger in the protocol, possibly influencing the disruption of the Ditto protocol and its minted stable assets (e.g., cUSD).

Tools Used

Manual Review

Recommendations

To fix the vulnerability, adopt the secondLiquidationTime delay instead of the firstLiquidationTime, as shown in the snippet below.

function setFlagger(
STypes.ShortRecord storage short,
address cusd,
uint16 flaggerHint
) internal {
AppStorage storage s = appStorage();
STypes.AssetUser storage flagStorage = s.assetUser[cusd][msg.sender];
//@dev Whenever a new flagger flags, use the flaggerIdCounter.
if (flagStorage.g_flaggerId == 0) {
address flaggerToReplace = s.flagMapping[flaggerHint];
uint256 timeDiff = flaggerToReplace != address(0)
? LibOrders.getOffsetTimeHours()
- s.assetUser[cusd][flaggerToReplace].g_updatedAt
: 0;
//@dev re-use an inactive flaggerId
- if (timeDiff > LibAsset.firstLiquidationTime(cusd)) {
+ if (timeDiff > LibAsset.secondLiquidationTime(cusd)) {
delete s.assetUser[cusd][flaggerToReplace].g_flaggerId;
short.flaggerId = flagStorage.g_flaggerId = flaggerHint;
} else if (s.flaggerIdCounter < type(uint16).max) {
//@dev generate brand new flaggerId
short.flaggerId = flagStorage.g_flaggerId = s.flaggerIdCounter;
s.flaggerIdCounter++;
} else {
revert Errors.InvalidFlaggerHint();
}
s.flagMapping[short.flaggerId] = msg.sender;
} else {
//@dev re-use flaggerId if flagger has an existing one
short.flaggerId = flagStorage.g_flaggerId;
}
short.updatedAt = flagStorage.g_updatedAt = LibOrders.getOffsetTimeHours();
}
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.