Platform: Code4rena
Start Date: 16/02/2023
Pot Size: $144,750 USDC
Total HM: 17
Participants: 154
Period: 19 days
Judge: Trust
Total Solo HM: 5
Id: 216
League: ETH
Rank: 16/154
Findings: 2
Award: $2,089.29
🌟 Selected for report: 0
🚀 Solo Findings: 0
2028.0263 USDC - $2,028.03
https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Core/contracts/StabilityPool.sol#L584
The global 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
.
The transaction then reverts due to the assert on assert(newP > 0)
.
In this state, further high-fraction liquidations would be blocked.
This bug was recently disclosed to liquity as a Moderate severity issue (5.9/10), link here: https://github.com/liquity/dev/security/advisories/GHSA-m9f3-hrx8-x2g3
An implicit assumption was that P
 never goes below 1e9
, since at P <1e9
, the scale changes and we multiply P
 by 1e9
. However depletions can leave the pool with much lesser P
and in those cases multiplying to 1e9
is not always the solution.
The calculations as defined in the issue mentioned above are similar for this issue as well (pasting them here for the sake of completion of the report):
The attack would be a 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.
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
The following change can be made to the function:
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 (currentP.mul(newProductFactor).div(DECIMAL_PRECISION) < SCALE_FACTOR) { + newP = newP.mul(SCALE_FACTOR); + currentScale = currentScaleCached.add(1); + emit ScaleUpdated(currentScale); + } } else { newP = currentP.mul(newProductFactor).div(DECIMAL_PRECISION); }
#0 - c4-judge
2023-03-09T17:30:13Z
trust1995 marked the issue as satisfactory
#1 - c4-judge
2023-03-09T17:30:19Z
trust1995 marked the issue as primary issue
#2 - tess3rac7
2023-03-15T01:53:44Z
Since this is a disclosed issue in the Liquity repo as of 2 weeks before the time of this comment, and we have made no changes to this piece of code that makes it different from the liquity liquidation logic, I'm not sure if this is a valid bug report made as part of the C4A content, or if it's just relaying of information that has been public knowledge since before the contest began. Requesting judge to weigh in.
#3 - c4-sponsor
2023-03-15T01:53:50Z
tess3rac7 requested judge review
#4 - trust1995
2023-03-20T11:47:56Z
The affected code is in scope, therefore imo the issue is valid unless communicated otherwise.
#5 - c4-judge
2023-03-20T16:13:44Z
trust1995 marked the issue as selected for report
#6 - c4-judge
2023-03-22T10:35:07Z
trust1995 marked issue #338 as primary and marked this issue as a duplicate of 338
🌟 Selected for report: GalloDaSballo
Also found by: 0x3b, 0xAgro, 0xSmartContract, 0xTheC0der, 0xackermann, 0xnev, 0xsomeone, ABA, BRONZEDISC, Bjorn_bug, Bnke0x0, Breeje, Co0nan, CodeFoxInc, CodingNameKiki, DadeKuma, DeFiHackLabs, IceBear, Josiah, Kaysoft, Lavishq, MohammedRizwan, PaludoX0, PawelK, Phantasmagoria, Raiders, RaymondFam, Rickard, Rolezn, Sathish9098, SleepingBugs, SuperRayss, UdarTeam, Udsen, Viktor_Cortess, arialblack14, ast3ros, bin2chen, brgltd, btk, catellatech, ch0bu, chaduke, chrisdior4, codeislight, cryptonue, delfin454000, descharre, dontonka, emmac002, fs0c, hacker-dom, hansfriese, imare, lukris02, luxartvinsec, martin, matrix_0wl, peanuts, rbserver, shark, tnevler, trustindistrust, tsvetanovv, vagrant, yongskiws, zzzitron
61.2601 USDC - $61.26
Letter | Name | Description |
---|---|---|
L | Low | Low severity/Potentially risky |
N | Non-critical | Non-risky findings |
Certain transfer restrictions imposed in the LUSDToken are not properly handled during the minting.
The LUSDToken forbids any transfer to a special set of addresses specified in the _requireValidRecipient
function:
function _requireValidRecipient(address _recipient) internal view { require( _recipient != address(0) && _recipient != address(this), "LUSD: Cannot transfer tokens directly to the LUSD token contract or the zero address" ); require( !stabilityPools[_recipient] && !troveManagers[_recipient] && !borrowerOperations[_recipient], "LUSD: Cannot transfer tokens directly to the StabilityPool, TroveManager or BorrowerOps" ); }
But these restrictions are not followed in minting. This could lead to the minting of tokens to invalid recipient addresses.
Add _requireValidRecipient(account) before any state changing operations in _mint
TCR is temporarily miscalculated in batchLiquidateTroves.
When calculating system's entire collateral, we should also exclude the liquidated trove's surplus collateral, since liquidation closes the trove and makes the surplus collateral claimable by the trove owner. This means, this line of code should look like this:
vars.entireSystemColl = vars.entireSystemColl. sub(singleLiquidation.collToSendToSP). sub(singleLiquidation.collSurplus);
The miscalculated entire collateral is used only to calculate the TCR and check if the system has been able to exit Recovery Mode. The miscalculation only persists temporarily, and within thebatchLiquidateTroves
 transaction. Once the transaction completes the TCR and Recovery Mode will be calculated properly again. However, the bug could negatively impact the liquidation throughput and the gas efficiency gains from batching multiple liquidations in a single transaction.
The potential impact can be:
As stated above the calculation should look like this:
vars.entireSystemColl = vars.entireSystemColl. sub(singleLiquidation.collToSendToSP). sub(singleLiquidation.collSurplus);
The contract ReaperStrategyGranarySupplyOnly
is an UUPS upgradeable contract but doesn’t have storage gap for any future variables that might be introduced. In order to be able to add new variables to the upgradeable abstract contract without causing storage collisions, a storage gap should be added to the upgradeable abstract contract.
If no storage gap is added, when the upgradable abstract contract introduces new variables, it may override the variables in the inheriting contract.
Consider adding storage gaps at the end of the contracts
uint256[50] private __gap;
Reference: https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
Permit
function can be front-run in LUSDToken.The permit
function can be front-run in LUSDToken. In case where a user has written a contract for a series of transfer with a permit function call, an attacker can front-run permit to revert the entire transaction.
Add this case in the documentation stating this case.
Require statements should have descriptive strings to describe why the revert occurs.
./TroveManager.sol:250: require(msg.sender == owner); ./TroveManager.sol:551: require(totals.totalDebtInSequence > 0); ./TroveManager.sol:716: require(_troveArray.length != 0); ./TroveManager.sol:754: require(totals.totalDebtInSequence > 0); ./TroveManager.sol:1450: require(redemptionFee < _collateralDrawn); ./TroveManager.sol:1523: require(msg.sender == borrowerOperationsAddress); ./TroveManager.sol:1527: require(msg.sender == address(redemptionHelper)); ./TroveManager.sol:1531: require(msg.sender == borrowerOperationsAddress || msg.sender == address(redemptionHelper)); ./TroveManager.sol:1535: require(Troves[_borrower][_collateral].status == Status.active); ./ReaperStrategyGranarySupplyOnly.sol:167: require(step[0] != address(0)); ./ReaperStrategyGranarySupplyOnly.sol:168: require(step[1] != address(0));
Instances:
./TroveManager.sol:1192: Troves[_borrower][_collateral].stake = 0; ./TroveManager.sol:1285: Troves[_borrower][_collateral].coll = 0; ./TroveManager.sol:1286: Troves[_borrower][_collateral].debt = 0; ./TroveManager.sol:1288: rewardSnapshots[_borrower][_collateral].collAmount = 0; ./TroveManager.sol:1289: rewardSnapshots[_borrower][_collateral].LUSDDebt = 0; ./TroveManager.sol:505: singleLiquidation.debtToRedistribute = 0; ./TroveManager.sol:506: singleLiquidation.collToRedistribute = 0; ./TroveManager.sol:387: singleLiquidation.debtToOffset = 0; ./TroveManager.sol:388: singleLiquidation.collToSendToSP = 0;
In contract ReaperBaseStrategyv4.sol
the variable PERCENT_DIVISOR
is defined but not used anywhere. Consider removing it.
uint256 public constant PERCENT_DIVISOR = 10_000;
In the contract ReaperBaseStrategyv4.sol
, during the authorization process of the UUPS upgrade, the permissions are limited to the following address having the DEFAULT_ADMIN_ROLE
set:
msg.sender
_multisigRoles[0]
However, no multi-signature validations are found in the codebase that is verified upon a new upgrade request.
In Upgradeable contracts like this, the owner can upgrade the contract without the community's commitment. If an attacker compromises the account, the owner(s) can change the implementation of the contract and drain tokens from the contract.
During contract initialization call to __ReaperBaseStrategy_init()
the following multisig account are given some permissions:
_grantRole(DEFAULT_ADMIN_ROLE, _multisigRoles[0]); _grantRole(ADMIN, _multisigRoles[1]); _grantRole(GUARDIAN, _multisigRoles[2]);
However, the call to __ReaperBaseStrategy_init()
may be reverted if the length of _multisigRoles
is less than 3.
Consider adding a require statement to check if the length of _multisigRoles
is less then 3, and revert if it is.
#0 - c4-judge
2023-03-09T17:28:15Z
trust1995 marked the issue as grade-b
#1 - c4-sponsor
2023-03-28T20:30:39Z
0xBebis marked the issue as sponsor acknowledged