Skip to content

High-fraction liquidations cause the global running product `P` to become less than `1e9`

Low
bingen published GHSA-m9f3-hrx8-x2g3 Mar 1, 2023

Package

contracts (Solidity)

Affected versions

1.0.0

Patched versions

None

Description

The global running product variable P can be made smaller than 1e9 via repeated liquidations that liquidate a high fraction of the SP.

When P becomes small enough, further liquidations can cause newP to evaluate to 0 in _updateRewardSumAndProduct L611:

newP=currentP.mul(newProductFactor).mul(SCALE_FACTOR).div(DECIMAL_PRECISION);

The transaction then reverts due to the assert on assert(newP > 0).

When the liquidation is a high fraction of the SP, newProductFactor (i.e. the ratio of the new SP size to its size prior to the liquidation) is therefore very small, and floor division rounds newP down to 0.

Impact

In this state, further high-fraction liquidations would be blocked. See test case 1 for a PoC.

How did this occur?

An implicit assumption was that P never goes below 1e9, since at P <1e9, the scale changes and we multiply P by 1e9, i.e. 1 billion. However, depletions can in fact leave the pool with much less than a billion’th - in fact, they can leave it at a billion billion’th (1e-18) of its size prior to the liquidation. At scale changes for these high-fraction liquidations, multiplying by 1e9 isn’t always sufficient to bring P back above 1e9.

Very high-fraction liquidations (i.e. those with very low newProductFactor) were not tested for, as evidenced in the original SP scale factor tests.

Simplest attack form

The simplest attack is the 3-liquidation sequence whereby each liquidation reduces the SP to the smallest possible fraction of its prior value, i.e. 1e-18.

Here, starting with the “best” value of P (P = 1e18), then after 2 liquidations, P = 1, and further high-fraction liquidations revert.

Numerical example (3 liquidations):

    To calculate newProductFactor we need:
    
    _LUSDLossPerUnitStaked = _debtToOffset * 1e18 / _totalLUSDDeposits + 1
    We need _LUSDLossPerUnitStaked to be 1e18 - 1. So:
    1e18 - 1 = _debtToOffset * 1e18 / _totalLUSDDeposits + 1
    1e18 - 2 = _debtToOffset * 1e18 / _totalLUSDDeposits
    _totalLUSDDeposits * (1e18 - 2) <= _debtToOffset * 1e18 < _totalLUSDDeposits * (1e18 - 1)
    Assuming _totalLUSDDeposits = 10,000e18
    10000e18 * (1e18 - 2) <= _debtToOffset * 1e18 < 10000e18 * (1e18 - 1)
    10000e18 * 999,999,999,999,999,998 <= _debtToOffset * 1e18 < 10000e18 * 999,999,999,999,999,999
    9999999999999999980000 <= _debtToOffset < 9999999999999999990000
    Translated to decimal notation:
    9999.999999999999980000 <= _debtToOffset < 9999.999999999999990000
    
    - Start:
    
    P = 1e18
    Deposits = 10,000e18
    
    - Liquidation 1:
    
    Burn: 9,999,999,999,999,999,980,000
    NewP = 1e18 * 1 * 1e9 / 1e18 = 1e9
    
    - Liquidation 2:
    
    New deposit: 9,999,999,999,999,999,980,000
    Total deposit = 10,000e18
    
    Burn: 9,999,999,999,999,999,980,000
    NewP = 1e9 * 1 * 1e9 / 1e18 = 1
    
    - Liquidation 3:
    
    New deposit: 9,999,999,999,999,999,980,000
    Total deposit = 10,000e18
    
    Burn: 9,999,999,999,999,999,980,000
    NewP = 1 * 1 * 1e9 / 1e18 = 0

Which other numbers could produce the same result?

In order for P to be reduced in _updateRewardSumAndProduct, L611:

             newP = currentP.mul(newProductFactor).mul(SCALE_FACTOR).div(DECIMAL_PRECISION); 

_LUSDLossPerUnitStaked needs to be in the range ]1e9, 1e18[, so that newProductFactor is in ]0, 1e9[.

But the lower _LUSDLossPerUnitStaked is (i.e., the bigger newProductFactor), the more iterations would be needed to reduce P to a tiny value. For instance, with newProductFactor = 1, 3 iterations would be needed to bring P from 1e18 to 0, but with newProductFactor = 1e6 (still offsetting 99.9999999999% the Stability Pool), 7 consecutive liquidations would be needed.

Impact of lastLUSDLossError_Offset

If there’s a remaining lastLUSDLossError_Offset from previous Stability Pool offset, it would decrease _LUSDLossPerUnitStaked, and therefore increase newProductFactor, reducing a bit the impact of the issue, but the effect should be quite small.

When can the attacker realistically do this?

Can the attacker do this in normal times when the SP is significant? Seemingly not, as such high-fraction liquidations have not been historically available (even in large crashes the liquidated fractions have come nowhere close to >99% of the SP).

As LQTY rewards and SP size decrease though, this may change.

However, the attacker can’t depend on liquidations of existing troves: they’d have to wait for a price drop and then compete with MEV liquidation bots, who are unlikely to perfectly liquidate the fraction needed by the attacker. They may liquidate more or less.

The only way to ensure the attack works is to wait until the SP is emptied by liquidation, then backrun pool-emptying liquidations with their own combination transaction. They would:

  • Create 2 troves at minimum debt
  • Liquidate them sequentially (potentially re-filling the SP in between in order to craft the precise high-fraction depletion)
  • Force P to equal 1

This can be done in 1 transaction.

However… the following fact renders this attack harmeless:

Even if P is made tiny (e.g P=1), not all future liquidations would be blocked

…In fact, only those liquidations which have very high depletion fractions > 99.9999999% will revert! That is, those which leave the SP with < 1 billion’th of its size prior to the liquidation (i.e. a very low newProductFactor < 1e9).

This means that as long as a liquidation either:

  1. Leaves the SP at a size > 1 billion’th of its size prior to liquidation
    or
  2. completely empties it (and changes epoch and resets P to 1e18)

then P does not evaluate to 0, and the scale changes and the liquidation succeeds.

So the attacker could only block a very small, specific range of liquidations that are extremely unlikely.

More importantly, liquidation flow cannot in practice blocked at all: it’s always possible to liquidate less than 99.9999999% of the SP, or deposit/withdraw from the SP to get a liquidation fraction that is not in the very narrow blocked range.

As long as the liquidation leaves the SP with > 1 billion’th of its prior value, P will actually increase (and the scale will change).

We can see this in the code. Assume P is its “worst” value, i.e. P=1:

newP=currentP.mul(newProductFactor).mul(SCALE_FACTOR).div(DECIMAL_PRECISION)
then
newP = 1 * newProductFactor * 1e9/1e18

When newProductFactor <1e9, then newP evaluates to 0 (and the liquidation reverts).

When newProductFactor == 1e9, then P does not change and the liquidation succeeds.

When the newProductFactor > 1e9, P increases and the liquidation succeeds.

This is proven in PoC test cases 3 and 4.

Can the attacker at least protect their own Trove?

Even though the attacker can only block specific liqs in range ]99.9999999%, 100%[ as fraction of total SP, could he at least protect his own Trove from liquidation if it had exactly the right debt and he has the only undercollateralized Trove?

The answer is no: someone could just withdraw or deposit to the Stability Pool to change the prospective depletion fraction, and liquidate the (failed) attacker. And a tiny amount of LUSD could be used: if the SP is 1 billion LUSD, then adding only 1 LUSD would shift the prospective liquidation out of the blocked range.

In summary, liquidations can not be blocked even when P is made as small as possible.

When P < 1e9, are there implications for user’s LUSD deposit or LQTY/ETH gain calculations?

Since we (probably, though not explicitly) assumed P > 1e9 when building Liquity, we need to check that lower values of P dont impact deposit or reward calculations.

Compounded deposit calculation

In _getCompoundedStakeFromSnapshots, the user’s depleted deposit is calculated this way:

...
   if (scaleDiff == 0) {
            compoundedStake = initialStake.mul(P).div(snapshot_P);
        } else if (scaleDiff == 1) {
            compoundedStake = initialStake.mul(P).div(snapshot_P).div(SCALE_FACTOR);
        } else { // if scaleDiff >= 2
            compoundedStake = 0;
        }
...

The else if (...) branch contains the logic for a depleted stake that experienced a scale change.

Does this correctly calculate a depositor’s compounded stake across a scale change when their P_snapshot <1e9?

Yes, it seems this does not depend on the specific value of P.

Let’s look again at the “worst” P value (P=1) and depletions that leave the SP with 1) 50% of prior, and 2) 1 billion’th of prior LUSD. The depositor has an initialStake of 1e18 in both cases.

1) Depletion of SP to 50% of prior value
Expected stake change: 1e18 –> 5e17

Initial state:

P: 1
initialStake: 1e18

Liquidation - depletion to 50%

newProductFactor = 5e17 (50%)
newP = 1*5e17*1e9/1e18
newP = 5e8

New depleted stake:

compoundedStake = initialStake.mul(P).div(snapshot_P).div(1e9)
= 1e18.mul(5e8).div(1).div(1e9)
= 5e(18+8-9)= 5e17  ( 👍expected value)

2) Depletion of SP to 1 billion’th of prior value
Expected stake change: 1e18 -> 1e9

Initial state:

P: 1
initialStake: 1e18

Liquidation - depletion to 1 billion’th

newProductFactor = 1e9  (1 billion’th)
newP = 1*1e9*1e9/1e18
newP = 1  (no change)

New depleted stake:

compoundedStake = initialStake.mul(P).div(snapshot_P).div(1e9)
= 1e18.mul(1).div(1).div(1e9)
= 1e9  ( 👍expected value)

Tests 5 and 6 cover these scenarios and explicitly confirm this behavior against the smart contracts.

LQTY and ETH gain calculations

The concern with P < 1e9 is that it could potentially cause overflow, since when calculating ETH gains and calculating LQTY gains we need to divide by P_snapshot, and this can be smaller than we assumed.

An overflow analysis for Stability Pool reward calculations has been previously performed in R&D. It found that if there were overflow, it would occur in the numerator’s multiplication before dividing by P_snapshot. As such, a small P_snapshot has no implication for LQTY or ETH gains overflow: overflow would happen (if at all) in the numerator multiplication, and dividing by P_snapshot is irrelevant: it at best reduces the numerator, and at worst does not change it.

Therefore a tiny value of P does not affect user LQTY or ETH gains.

Patches

Proposed solutions (TODO):

Even though this is 1) essentially a non-issue 2) can only block a very narrow range of liquidations and 3) cannot block liquidation flow in general, a fundamental fix for the code is possible.

One approach would be to increment the scale factor by 2, if incrementing by only 1 would leave P < 1e9. For example:

diff --git a/packages/contracts/contracts/StabilityPool.sol b/packages/contracts/contracts/StabilityPool.sol
index b97756cc..20e8ba94 100644
--- a/packages/contracts/contracts/StabilityPool.sol
+++ b/packages/contracts/contracts/StabilityPool.sol
@@ -611,6 +611,12 @@ contract StabilityPool is LiquityBase, Ownable, CheckContract, IStabilityPool {
             newP = currentP.mul(newProductFactor).mul(SCALE_FACTOR).div(DECIMAL_PRECISION); 
             currentScale = currentScaleCached.add(1);
             emit ScaleUpdated(currentScale);
+            // If it’s still smaller than the SCALE_FACTOR, increment scale again. Afterwards it couldn’t happen again, as DECIMAL_PRECISION = SCALE_FACTOR^2
+            if (newP < SCALE_FACTOR) {
+                newP = newP.mul(SCALE_FACTOR);
+                currentScale = currentScaleCached.add(2);
+                emit ScaleUpdated(currentScale);
+            }
         } else {
             newP = currentP.mul(newProductFactor).div(DECIMAL_PRECISION);
         }

With this adjustment, care should be taken to ensure that depositor rewards and LQTY/ETH gains calculations remain correct - and they may need to be modified as well.

Another suggested fix from Kristián Balaj (@KristianBalaj) is to alter the factor by which P is multiplied when a scale change occurs:

image

Summary

  • P<1e9 can occur via a sequence of very high-fraction liquidations, contrary to initial assumptions
  • Further high fraction liquidations would revert
  • Even with tiny P, the range of liquidations that could revert is extremely narrow
  • A liquidator can always liquidate undercollateralized Troves: even if their target Trove(s) constitutes a fraction of the SP that falls inside the blocked range, they can shift it out by depositing/withdrawing a small amount to the SP (~1 LUSD is sufficient).
  • There is no implication of P < 1e9 for deposit or reward calculations, which still work as expected

Bug bounty

A reward of $10,000 was awarded to Kristián Balaj (@KristianBalaj) for reporting this bug.

For more information

If you have any questions or comments about this advisory:

Severity

Low

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
High
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:N/A:L

CVE ID

No known CVE

Weaknesses

No CWEs

Credits