DittoETH

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

Minting stable assets at prices lower than actual via liquidate()

Summary

The MarginCallPrimaryFacet::liquidate() is vulnerable to front-running and price manipulation attacks, leading to the minting of stable assets (e.g., cUSD) at prices lower than the actual price of the targeted asset (fed by Chainlink).

The root cause of this vulnerability is that an attacker can place a forced Bid order to match Short orders that have prices below the actual price. As a result, the attack can eventually result in the de-pegging of the minted assets.

Vulnerability Details

Assuming that an attacker is a Sybil account of the shorter who possesses the under-collateralized Short order that will be liquidated (i.e., liquidating their own Short via a Sybil account). The attacker's goal is to extract the maximal value by fully liquidating their own Short order and receive the maximal collateral (zETH) back.

Under the hood, the liquidate() uses the protocol's order book to make a "forced Bid" on the target Short order. The remaining collateral will be used to buy back their ercDebt (e.g., cUSD). In order to extract the maximal value, the attacker must expect to buy back their ercDebt by spending the least amount of collateral.

In case the forced Bid order matches Ask orders, the current oracle price will not be taken into account. Conversely, if the forced Bid order matches Short orders, those matched Short orders must have prices at or above the oracle price. This constraint maintains the minting of stable assets to the actual price of the targeted asset (e.g., cUSD is pegged with USD) unless the minted assets could eventually be de-pegged.

However, the liquidate() is vulnerable to front-running and price manipulation attacks, leading to the minting of stable assets at prices lower than the actual price of the targeted asset.

To elaborate on the vulnerability, please consider the following section.

Explaining how the liquidate() can be attacked by front-running and price manipulation

After flagging the target Short order and the waiting period (first liquidation time) is over, the attacker executes the liquidate() to liquidate their own Short order.

Even if the liquidate() will execute the LibOrders::updateOracleAndStartingShortViaTimeBidOnly() that will update the protocol's oracle price (cache) and start the process of finding the new startingShortId if the last updated timestamp is more than or equal to 15 minutes (i.e., oracleFrequency == OF.FifteenMinutes).

The updateOracleAndStartingShortViaTimeBidOnly() cannot guarantee that suddenly after the price fed by Chainlink has been updated, the protocol's cached price will be immediately updated (because of the condition: timeDiff >= 15 minutes ).

Let's say the price updated by Chainlink is higher than the protocol's cached price. The attacker can front-run the protocol's oracle price update and execute the liquidate() to place the forced Bid order with price == the protocol's cached price (stale price). Because of the condition: timeDiff >= 15 minutes, the updateOracleAndStartingShortViaTimeBidOnly() will not trigger the _updateOracleAndStartingShort(). Therefore, the protocol's cached price will not be updated.

Then, the liquidate() will invoke the _performForcedBid() to make a "forced Bid" on the target Short order.

// 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
)
...
{
if (msg.sender == shorter) revert Errors.CannotLiquidateSelf();
//@dev marginCall requires more up-to-date oraclePrice (15 min vs createLimitBid's 1 hour)
@> LibOrders.updateOracleAndStartingShortViaTimeBidOnly(
@> asset, OF.FifteenMinutes, shortHintArray
@> );
MTypes.MarginCallPrimary memory m = _setMarginCallStruct(asset, shorter, id);
...
@> _performForcedBid(m, shortHintArray);
...
}
  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L104-L106

  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L124

The _performForcedBid() will then call the BidOrdersFacet::createForcedBid().

// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/facets/MarginCallPrimaryFacet.sol
function _performForcedBid(
MTypes.MarginCallPrimary memory m,
uint16[] memory shortHintArray
) private {
uint256 startGas = gasleft();
uint88 ercAmountLeft;
//@dev Provide higher price to better ensure it can fully fill the margin call
uint80 _bidPrice = m.oraclePrice.mulU80(m.forcedBidPriceBuffer);
...
// @dev MarginCall contract will be the caller. Virtual accounting done later for shorter or TAPP
@> (m.ethFilled, ercAmountLeft) = IDiamond(payable(address(this))).createForcedBid(
@> address(this), m.asset, _bidPrice, m.short.ercDebt, shortHintArray
@> );
...
}
  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L239-L241

The BidOrdersFacet::createForcedBid() will execute the _createBid() to create a "forced Bid" on the target Short order.

Once the _createBid() loads the oracle price (b.oraclePrice) from the cached storage, the stale price will be consumed.

Even though the _createBid() will execute the LibOrders::updateOracleAndStartingShortViaThreshold() to make sure that if the price difference between the forced Bid order's and the protocol's cached oracle is greater than 0.5% deviation , the _updateOracleAndStartingShort() will be triggered to update the protocol's oracle price and start the process of finding the new startingShortId.

Since the attacker places the forced Bid order with price == the protocol's cached price, the execution of the _updateOracleAndStartingShort() will be bypassed.

Eventually, the _createBid() will execute the bidMatchAlgo() to match the attacker's forced Bid order with Sell orders (Ask or Short orders) whose prices are less than the actual price (Chainlink). As described earlier, matching Ask orders whose prices are below the actual price is the correct implementation.

Nonetheless, the bidMatchAlgo() also matches the forced Bid order with Short orders whose prices are below the actual price (fed by Chainlink) since the protocol's cached price is outdated. Subsequently, the protocol will mint the stable asset at a price below the actual price. In other words, the minting mechanism will eventually catalyze the minted asset to depeg.

// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/facets/BidOrdersFacet.sol
function createForcedBid(
address sender,
address asset,
uint80 price,
uint88 ercAmount,
uint16[] calldata shortHintArray
) external onlyDiamond returns (uint88 ethFilled, uint88 ercAmountLeft) {
//@dev leave empty, don't need hint for market buys
MTypes.OrderHint[] memory orderHintArray;
// @dev update oracle in callers
@> return _createBid(
sender,
asset,
price,
ercAmount,
Constants.MARKET_ORDER,
orderHintArray,
shortHintArray
);
}
function _createBid(
address sender,
address asset,
uint80 price,
uint88 ercAmount,
bool isMarketOrder,
MTypes.OrderHint[] memory orderHintArray,
uint16[] memory shortHintArray
) private returns (uint88 ethFilled, uint88 ercAmountLeft) {
...
MTypes.BidMatchAlgo memory b;
@> b.oraclePrice = LibOracle.getPrice(asset);
b.askId = s.asks[asset][Constants.HEAD].nextId;
//@dev setting initial shortId to match "backwards" (See _shortDirectionHandler() below)
b.shortHintId = b.shortId = Asset.startingShortId;
emit Events.CreateBid(asset, sender, incomingBid.id, incomingBid.creationTime);
STypes.Order memory lowestSell = _getLowestSell(asset, b);
if (
incomingBid.price >= lowestSell.price
&& (
lowestSell.orderType == O.LimitAsk || lowestSell.orderType == O.LimitShort
)
) {
//@dev if match and match price is gt .5% to saved oracle in either direction, update startingShortId
@> LibOrders.updateOracleAndStartingShortViaThreshold(
@> asset, b.oraclePrice, incomingBid, shortHintArray
@> );
b.shortHintId = b.shortId = Asset.startingShortId;
@> return bidMatchAlgo(asset, incomingBid, orderHintArray, b);
} else {
//@dev no match, add to market if limit order
LibOrders.addBid(asset, incomingBid, orderHintArray);
return (0, ercAmount);
}
}
  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/BidOrdersFacet.sol#L92

  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/BidOrdersFacet.sol#L129

  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/BidOrdersFacet.sol#L144-L146

  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/BidOrdersFacet.sol#L148

Further explaining why the attacker can extract the maximal value after the attack

After the target Short order is fully liquidated, the liquidate() will execute the _marginFeeHandler() to calculate the tappFee (Protocol fee) and callerFee (Margin caller fee).

At this point, the ethFilled will become the smallest amount (according to the attacker's goal: receiving the maximal collateral (zETH) back). Hence, both tappFee and callerFee will be lower than they should be (the attacker does not care about the callerFee, though).

// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/facets/MarginCallPrimaryFacet.sol
function _marginFeeHandler(MTypes.MarginCallPrimary memory m) private {
...
// distribute fees to TAPP and caller
@> uint88 tappFee = m.ethFilled.mulU88(m.tappFeePct);
@> uint88 callerFee = m.ethFilled.mulU88(m.callerFeePct) + m.gasFee;
...
}
  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L266

  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L267

Finally, the liquidate() will execute the _fullorPartialLiquidation() to return the remaining collateral (zETH) to the liquidated shorter (i.e., the attacker themselves). Since the ethFilled would be the smallest amount (previously discussed), the computed decreaseCol variable will also contain the smallest number.

Consequently, the attacker will receive the maximal collateral (zETH) back.

// FILE: https://github.com/Cyfrin/2023-09-ditto/blob/main/contracts/facets/MarginCallPrimaryFacet.sol
function _fullorPartialLiquidation(MTypes.MarginCallPrimary memory m) private {
@> uint88 decreaseCol = min88(m.totalFee + m.ethFilled, m.short.collateral);
if (m.short.ercDebt == m.ercDebtMatched) {
// Full liquidation
...
if (!m.loseCollateral) {
@> m.short.collateral -= decreaseCol;
@> s.vaultUser[m.vault][m.shorter].ethEscrowed += m.short.collateral;
@> s.vaultUser[m.vault][address(this)].ethEscrowed -= m.short.collateral;
}
} else {
// Partial liquidation
...
}
}
  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L294

  • https://github.com/Cyfrin/2023-09-ditto/blob/a93b4276420a092913f43169a353a6198d3c21b9/contracts/facets/MarginCallPrimaryFacet.sol#L307-L309

Impact

The liquidate() is vulnerable to front-running and price manipulation attacks, leading to the minting of stable assets (e.g., cUSD) at prices lower than the actual price of the targeted asset (fed by Chainlink).

The attacker (i.e., the Sybil account of the liquidated shorter) can extract the maximal value by fully liquidating their own Short order and receive the maximal collateral (zETH) back.

However, the impact on the Ditto protocol is enormous since it can finally result in the de-pegging of the minted assets.

Tools Used

Manual Review

Recommendations

Since the cached oracle price is prone to front-running and price manipulation attacks, always execute the LibOracle::getOraclePrice() to get the accurate price from Chainlink.

Updates

Lead Judging Commences

0xnevi Lead Judge
almost 2 years ago
0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Invalidated
Reason: Other
serialcoder Submitter
almost 2 years ago
0xnevi Lead Judge
almost 2 years ago
0xnevi Lead Judge almost 2 years ago
Submission Judgement Published
Invalidated
Reason: Other

Support

FAQs

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