sherlock-audit / 2024-06-new-scope-judging

1 stars 1 forks source link

A2-security - Inconsistent Application of Reserve Factor Changes Leads to Protocol Insolvency Risk #402

Open sherlock-admin3 opened 2 months ago

sherlock-admin3 commented 2 months ago

A2-security

Medium

Inconsistent Application of Reserve Factor Changes Leads to Protocol Insolvency Risk

Summary

The ZeroLend protocol's PoolFactory allows for global changes to the reserveFactor, which affects all pools simultaneously. However, the ReserveLogic contract applies this change inconsistently between interest accrual and treasury minting processes. This inconsistency leads to a mismatch between accrued interest for users and the amount minted to the treasury, causing protocol insolvency or locked funds.

Vulnerability Detail

The reserveFactor is a crucial parameter in the protocol that determines the portion of interest accrued from borrowers that goes to the protocol's treasury. It's defined in the PoolFactory contract:

contract PoolFactory is IPoolFactory, Ownable2Step {
// ...
uint256 public reserveFactor;
// ...
}

This reserveFactor is used across all pools created by the factory. It's retrieved in various operations, such as in the supply function for example :

function _supply(address asset, uint256 amount, bytes32 pos, DataTypes.ExtraData memory data) internal nonReentrant(RentrancyKind.LENDING) returns (DataTypes.SharesType memory res) {
// ...
res = SupplyLogic.executeSupply(
_reserves[asset],
_usersConfig[pos],
_balances[asset][pos],
_totalSupplies[asset],
DataTypes.ExecuteSupplyParams({reserveFactor: _factory.reserveFactor(), /_ ... _/})
);
// ...
}

The reserveFactor plays a critical role in calculating interest rates and determining how much of the accrued interest goes to the liquidity providers and how much goes to the protocol's treasury . The issue arises from the fact that this reserveFactor can be changed globally for all pools:

function setReserveFactor(uint256 updated) external onlyOwner {
uint256 old = reserveFactor;
reserveFactor = updated;
emit ReserveFactorUpdated(old, updated, msg.sender);
}

let's examine how this change affects the core logic in the ReserveLogic contract:

function updateState(DataTypes.ReserveData storage self, uint256 _reserveFactor, DataTypes.ReserveCache memory _cache) internal {
if (self.lastUpdateTimestamp == uint40(block.timestamp)) return;

    _updateIndexes(self, _cache);
    _accrueToTreasury(_reserveFactor, self, _cache);

    self.lastUpdateTimestamp = uint40(block.timestamp);

}

The vulnerability lies in the fact that _updateIndexes and _accrueToTreasury will use different reserveFactor values when a change occurs:

if the reserveFactors is changed _updateIndexes will uses the old reserveFactor implicitly through cached liquidityRate:

function _updateIndexes(DataTypes.ReserveData storage _reserve, DataTypes.ReserveCache memory _cache) internal {
if (_cache.currLiquidityRate != 0) {
uint256 cumulatedLiquidityInterest = MathUtils.calculateLinearInterest(_cache.currLiquidityRate, _cache.reserveLastUpdateTimestamp);
_cache.nextLiquidityIndex = cumulatedLiquidityInterest.rayMul(_cache.currLiquidityIndex).toUint128();
_reserve.liquidityIndex = _cache.nextLiquidityIndex;
}
// ...
}

_accrueToTreasury will use the new reserveFactor:

function _accrueToTreasury(uint256 reserveFactor, DataTypes.ReserveData storage _reserve, DataTypes.ReserveCache memory _cache) internal {
// ...
vars.amountToMint = vars.totalDebtAccrued.percentMul(reserveFactor);
if (vars.amountToMint != 0) _reserve.accruedToTreasuryShares += vars.amountToMint.rayDiv(_cache.nextLiquidityIndex).toUint128();
}

This discrepancy results in the protocol minting more/less treasury shares than it should based on the actual accrued interest cause it uses the new reserveFactor. Over time, this can lead to a substantial overallocation/underallocation of funds to the treasury, depleting the reserves available for users or leaving funds locked in the pool contract forever.

example scenario :

After 2 months:

When a user attempts to withdraw:

However, the borrower only repaid 10,200 USD (10,000 principal + 200 interest), resulting in a 40 USD shortfall. This discrepancy can lead to failed withdrawals and insolvency of the protocol.

Impact

the Chage of reserveFactor leads to protocol insolvency risk or locked funds. Increased reserveFactor causes over-minting to treasury, leaving insufficient funds for user withdrawals. Decreased reserveFactor results in under-minting, locking tokens in the contract permanently. Both scenarios compromise the protocol's financial integrity

Code Snippet

Tool used

Manual Review

Recommendation

Add a new state variable in the ReserveData struct:

struct ReserveData {
    // ... existing fields
+    uint256 lastReserveFactor;
}

Modify the updateState function to use and update this lastReserveFactor:

function updateState(DataTypes.ReserveData storage self, uint256 _reserveFactor, DataTypes.ReserveCache memory _cache) internal {
    if (self.lastUpdateTimestamp == uint40(block.timestamp)) return;

    _updateIndexes(self, _cache);
-   _accrueToTreasury(_reserveFactor, self, _cache);
+   _accrueToTreasury(self.lastReserveFactor, self, _cache);

    self.lastUpdateTimestamp = uint40(block.timestamp);
+   self.lastReserveFactor = _reserveFactor;
}

This solution ensures that the same reserveFactor is used for both interest accrual and treasury minting within each update cycle, preventing inconsistencies while allowing for global reserveFactor changes to take effect gradually across all pools.

sherlock-admin2 commented 2 months ago

1 comment(s) were left on this issue during the judging contest.

Honour commented:

invalid: the cached reserveFactor is also the same used to accrue to treasury.

nevillehuang commented 2 months ago

request poc

Seems related to #199

sherlock-admin3 commented 2 months ago

PoC requested from @A2-security

Requests remaining: 19

aliX40 commented 2 months ago

hey @nevillehuang ,this is not a dup of #199 , we have #316 which is duplicate of #199 . this one is different

The issue described in the report, is similar to a bug found in the aave v3 codebase when updating the reserveFactor. This bug have been disclosed and fixed with the v3.1 release https://github.com/aave-dao/aave-v3-origin/blob/3aad8ca184159732e4b3d8c82cd56a8707a106a2/src/core/contracts/protocol/pool/PoolConfigurator.sol#L300C1-L315C4

  function setReserveFactor(
    address asset,
    uint256 newReserveFactor
  ) external override onlyRiskOrPoolAdmins {
    require(newReserveFactor <= PercentageMath.PERCENTAGE_FACTOR, Errors.INVALID_RESERVE_FACTOR);

  @>>   _pool.syncIndexesState(asset);

    DataTypes.ReserveConfigurationMap memory currentConfig = _pool.getConfiguration(asset);
    uint256 oldReserveFactor = currentConfig.getReserveFactor();
    currentConfig.setReserveFactor(newReserveFactor);
    _pool.setConfiguration(asset, currentConfig);
    emit ReserveFactorChanged(asset, oldReserveFactor, newReserveFactor);

    _pool.syncRatesState(asset);
  }

Also the fix we recomended is inspired by how the eulerv2 handled this, in their vault. (cache reserve factor when calling updateInterestrate, and use the cached factor when updating the index!)

0xspearmint1 commented 1 month ago

escalate

setReserveFactor() is a protocol admin function

Sherlock rules state

Admin could have an incorrect call order. Example: If an Admin forgets to setWithdrawAddress() before calling withdrawAll() This is not a valid issue.

An admin action can break certain assumptions about the functioning of the code. Example: Pausing a collateral causes some users to be unfairly liquidated or any other action causing loss of funds. This is not considered a valid issue.

  1. If the admin calls forceUpdateReserve() on the pools before calling setReserveFactor() this issue will not exist

  2. Since setReserveFactor() is only called by the protocol admin, according to the sherlock rules admin actions that lead to issues are not valid

sherlock-admin3 commented 1 month ago

escalate

setReserveFactor() is a protocol admin function

Sherlock rules state

Admin could have an incorrect call order. Example: If an Admin forgets to setWithdrawAddress() before calling withdrawAll() This is not a valid issue.

An admin action can break certain assumptions about the functioning of the code. Example: Pausing a collateral causes some users to be unfairly liquidated or any other action causing loss of funds. This is not considered a valid issue.

  1. If the admin calls forceUpdateReserve() on the pools before calling setReserveFactor() this issue will not exist

  2. Since setReserveFactor() is only called by the protocol admin, according to the sherlock rules admin actions that lead to issues are not valid

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

aliX40 commented 1 month ago

First point:

This is not true and presents a Dos attack vector. Creating pools on zerolend is permissionless, u can't simply expect the admin to call forceUpdateReserve() on 10 of thousands of pools before changing the resrve factor. This is simply unrealistic, costly and opens an attack vector for people to dos the treasury Second point:

0xDenzi commented 1 month ago

I would also like to clarify further for the escalator that the 2nd point does not apply to functions which itself are broken/incomplete. The issue is not about admin missing or not executing or delaying a call or providing a wrong input. The issue is that the function is missing line/s of code to properly adjust the reserve factor.

cvetanovv commented 1 month ago

This issue falls right between the "Admin Input/call validation" rules and broken functionality:

Admin could have an incorrect call order. An admin action can break certain assumptions about the functioning of the code.

Breaks core contract functionality, rendering the contract useless or leading to loss of funds of the affected party larger than 0.01% and 10 USD.

But I think we have broken functionality here, not an admin error.

Planning to reject the escalation and leave the issue as is.

WangSecurity commented 4 weeks ago

Result: Medium Has duplicates

sherlock-admin4 commented 4 weeks ago

Escalations have been resolved successfully!

Escalation status: