Zero Exchange Rate Bug: Preventing DeFi Bad Debt
Hey guys! Today, we're diving deep into a critical vulnerability found in the Dandy Licorice Liger protocol that could lead to some serious bad debt. We're talking about a scenario where a division by zero error can occur, effectively halting liquidations and potentially threatening the solvency of the entire system. So, buckle up and let's get technical, but in a way that's super easy to understand!
The Nitty-Gritty: Division by Zero in Operator.liquidateCalculateSeizeTokens
The heart of the issue lies within the Operator.liquidateCalculateSeizeTokens
function. This function is crucial for calculating the number of collateral tokens to seize during a liquidation. You see, when someone's loan becomes undercollateralized, the protocol needs to liquidate their collateral to cover the debt. However, if the exchange rate of the collateral market hits zero, we've got a problem. A big one.
In this function, there’s a calculation that involves dividing by a denominator derived from the collateral market's exchange rate. If this exchange rate is zero, the denominator becomes zero, leading to a classic division by zero error. This isn't just a minor hiccup; it’s a full-stop roadblock for liquidations using that specific collateral market. Think of it like trying to drive your car with no gas – you're going nowhere!
denominator = mul_(Exp({mantissa: priceCollateralMantissa}), Exp({mantissa: exchangeRateMantissa}));
ratio = div_(numerator, denominator);
Now, you might be wondering, how does the exchange rate even hit zero? Well, it's all about the math behind the mTokenStorage._exchangeRateStored
function. The exchange rate is calculated using this formula:
uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply;
If totalCash + totalBorrows - totalReserves
equals zero, the exchange rate becomes zero. This can happen in markets with low liquidity, where a large borrowing event or other market manipulations can throw the numbers out of whack. Imagine a small pond where someone suddenly drains all the water – the ecosystem collapses, right? Similarly, a market with zero exchange rate is a market in deep trouble.
Setting the Stage: Pre-Conditions for the Attack
Before this division by zero debacle can occur, we need a few things to fall into place. These are the internal pre-conditions:
- A market's
totalCash + totalBorrows - totalReserves
has to equal zero. This is the trigger point, the moment the bomb is armed. - This specific market must be used as collateral for some existing positions. If nobody’s using this market as collateral, the problem is moot.
- Some of these positions have to be undercollateralized and ripe for liquidation. We need borrowers who are failing to meet their collateral requirements.
And then, we have the external pre-conditions, the circumstances that make this scenario more likely:
- The market needs to have low liquidity or be susceptible to manipulation. This is the dry wood that makes the fire burn hotter. If a market is robust and liquid, it's harder to manipulate.
- There's no quick governance intervention to fix the zero exchange rate. If the protocol can't react swiftly, the problem can snowball.
The Attack Path: How It All Goes Down
Let's paint a picture of how an attacker might exploit this vulnerability. It's like a heist movie, but with smart contracts instead of masked robbers:
- The Recon: The attacker identifies a market with low liquidity. This is their target, the vulnerable spot in the system.
- The Manipulation: They manipulate the market, perhaps by borrowing all the available liquidity. This is the setup, the careful planning before the execution.
- Zero Hour: The exchange rate plunges to zero. The critical moment, the point of no return.
- Liquidation Lockout: Any attempts to liquidate positions using this market as collateral fail, thanks to the division by zero error. The alarm bells are ringing, but nobody can respond.
- Bad Debt Accumulation: Bad debt starts piling up in the protocol. The damage is done, and the protocol is bleeding.
Think of it as a domino effect: one small manipulation triggers a chain reaction, leading to a cascade of failures and financial loss.
The Impact: Why This Matters
The consequences of this vulnerability are far-reaching and potentially devastating. We're not just talking about a minor inconvenience; this could cripple the protocol. Here's a breakdown of the impact:
- Liquidation Freeze: Positions that should be liquidated simply can't be. This is like a traffic jam on the financial highway, with assets stuck in limbo.
- Bad Debt Avalanche: Bad debt accumulates rapidly, eroding the protocol's financial health. It's like a leaky faucet that slowly floods the entire house.
- Solvency Threat: If the bad debt grows large enough, the protocol's solvency is at risk. This is the worst-case scenario, where the entire system could collapse.
- Erosion of Trust: User confidence in the protocol plummets. Nobody wants to put their money into a system that's vulnerable and unreliable.
This isn't just about numbers on a screen; it's about real money and real people's investments. A vulnerability like this can shake the very foundations of a DeFi protocol.
Proof of Concept: Seeing the Error in Action
To drive the point home, let's look at a simplified code example that demonstrates the vulnerability. It's like a fire drill, showing exactly how the error occurs.
// Assume we have a market with low liquidity
address mTokenCollateral = address(lowLiquidityMarket);
// Manipulate the market to make totalCash + totalBorrows - totalReserves = 0
// This could be done by borrowing all available liquidity
// Now try to calculate seize tokens for a liquidation
uint256 repayAmount = 100; // Some amount
uint256 seizeTokens = operator.liquidateCalculateSeizeTokens(
address(borrowedMarket),
mTokenCollateral,
repayAmount
);
// This will revert with division by zero error
This code snippet shows how attempting to calculate seizeTokens
after the market has been manipulated will result in a division by zero error. It's a clear and concise demonstration of the problem.
The Fix: Mitigation Strategies
So, how do we prevent this disaster? Thankfully, there are a couple of effective mitigation strategies we can implement. It's like having a fire extinguisher ready to put out the flames before they spread.
1. Zero Exchange Rate Check:
We can add a check within the Operator.liquidateCalculateSeizeTokens
function to ensure the exchange rate isn't zero before performing the division. This is like putting a safety lock on the gun.
function liquidateCalculateSeizeTokens(address mTokenBorrowed, address mTokenCollateral, uint256 actualRepayAmount)
external
view
returns (uint256)
{
uint256 priceBorrowedMantissa = IOracleOperator(oracleOperator).getUnderlyingPrice(mTokenBorrowed);
uint256 priceCollateralMantissa = IOracleOperator(oracleOperator).getUnderlyingPrice(mTokenCollateral);
require(priceBorrowedMantissa > 0 && priceCollateralMantissa > 0, Operator_PriceFetchFailed());
uint256 exchangeRateMantissa = ImToken(mTokenCollateral).exchangeRateStored();
require(exchangeRateMantissa > 0, "Exchange rate cannot be zero"); // Add this check
Exp memory numerator;
Exp memory denominator;
Exp memory ratio;
numerator = mul_(
Exp({mantissa: liquidationIncentiveMantissa[mTokenCollateral]}), Exp({mantissa: priceBorrowedMantissa})
);
denominator = mul_(Exp({mantissa: priceCollateralMantissa}), Exp({mantissa: exchangeRateMantissa}));
ratio = div_(numerator, denominator);
return mul_ScalarTruncate(ratio, actualRepayAmount);
}
This simple check acts as a safeguard, preventing the division by zero error from ever occurring.
2. Minimum Exchange Rate Implementation:
Alternatively, we can enforce a minimum exchange rate within the mTokenStorage._exchangeRateStored
function. This ensures the exchange rate never actually hits zero, acting like a floor that prevents the market from collapsing completely.
function _exchangeRateStored() internal view virtual returns (uint256) {
uint256 _totalSupply = totalSupply;
if (_totalSupply == 0) {
return initialExchangeRateMantissa;
} else {
uint256 totalCash = _getCashPrior();
uint256 cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
// Ensure a minimum exchange rate to prevent division by zero
if (cashPlusBorrowsMinusReserves == 0) {
return 1; // Minimum exchange rate
}
uint256 exchangeRate = (cashPlusBorrowsMinusReserves * expScale) / _totalSupply;
return exchangeRate;
}
}
By returning a minimum value (like 1) when cashPlusBorrowsMinusReserves
is zero, we prevent the division by zero and maintain a functioning market, even under duress.
Conclusion: Staying Vigilant in DeFi
This vulnerability highlights the importance of rigorous auditing and careful consideration of edge cases in DeFi protocols. We've seen how a seemingly small mathematical error can have significant consequences, potentially leading to bad debt and solvency issues.
By understanding the root cause, attack path, and impact of this vulnerability, we can better protect ourselves and the DeFi ecosystem as a whole. Implementing the proposed mitigation strategies is crucial for ensuring the stability and reliability of the Dandy Licorice Liger protocol.
Stay safe out there, guys, and keep those smart contracts secure!