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:
- Leaves the SP at a size > 1 billion’th of its size prior to liquidation
or
- 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:
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:
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:
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:
The global running product variable
P
can be made smaller than1e9
via repeated liquidations that liquidate a high fraction of the SP.When
P
becomes small enough, further liquidations can causenewP
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 roundsnewP
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 below1e9
, since atP <1e9
, the scale changes and we multiplyP
by1e9
, 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 by1e9
isn’t always sufficient to bringP
back above1e9
.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):
Which other numbers could produce the same result?
In order for
P
to be reduced in _updateRewardSumAndProduct, L611:_LUSDLossPerUnitStaked
needs to be in the range]1e9, 1e18[
, so thatnewProductFactor
is in]0, 1e9[
.But the lower
_LUSDLossPerUnitStaked
is (i.e., the biggernewProductFactor
), the more iterations would be needed to reduceP
to a tiny value. For instance, withnewProductFactor = 1
, 3 iterations would be needed to bringP
from1e18
to0
, but withnewProductFactor = 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 increasenewProductFactor
, 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:
This can be done in 1 transaction.
However… the following fact renders this attack harmeless:
Even if
P
is made tiny (e.gP=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:
or
P
to1e18
)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
, thennewP
evaluates to 0 (and the liquidation reverts).When
newProductFactor == 1e9
, thenP
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 ofP
dont impact deposit or reward calculations.Compounded deposit calculation
In
_getCompoundedStakeFromSnapshots
, the user’s depleted deposit is calculated this way: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 aninitialStake
of1e18
in both cases.1) Depletion of SP to 50% of prior value
Expected stake change:
1e18
–>5e17
Initial state:
Liquidation - depletion to 50%
New depleted stake:
2) Depletion of SP to 1 billion’th of prior value
Expected stake change: 1e18 -> 1e9
Initial state:
Liquidation - depletion to 1 billion’th
New depleted stake:
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 byP_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 smallP_snapshot
has no implication for LQTY or ETH gains overflow: overflow would happen (if at all) in the numerator multiplication, and dividing byP_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: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:
Summary
P<1e9
can occur via a sequence of very high-fraction liquidations, contrary to initial assumptionsP
, the range of liquidations that could revert is extremely narrowP < 1e9
for deposit or reward calculations, which still work as expectedBug 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: