Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Nihavent - CuratedVaultSetters::_supplyPool() does not consider the pool cap of the underlying pool, which may cause deposit() to revert or lead to an unintended reordering of supplyQueue #433

Open
sherlock-admin3 opened this issue Sep 10, 2024 · 22 comments
Labels
Escalation Resolved This issue's escalations have been approved/rejected Has Duplicates A valid issue with 1+ other issues describing the same vulnerability Medium A Medium severity issue. Reward A payout will be made for this issue

Comments

@sherlock-admin3
Copy link
Contributor

sherlock-admin3 commented Sep 10, 2024

Nihavent

Medium

CuratedVaultSetters::_supplyPool() does not consider the pool cap of the underlying pool, which may cause deposit() to revert or lead to an unintended reordering of supplyQueue

Summary

The curated vault's _supplyPool() function deposits assets into the underlying pools in supplyQueue. Whilst it considers the curated vault's cap for a given pool, it does not consider the underlying pool's internal cap. As a result, some CuratedVault::deposit() transactions will revert due to running out of pools to deposit to, or the liquidity will be allocated to the supplyQueue in a different order.

Root Cause

CuratedVaultSetters::_supplyPool() is called in the deposit flow of the curated vault. As shown below, it attempts to deposit the minimum of assets and supplyCap - supplyAssets (which is the 'available curated pool cap' for this pool).

  function _supplyPool(uint256 assets) internal {
    for (uint256 i; i < supplyQueue.length; ++i) {
      IPool pool = supplyQueue[i];

      uint256 supplyCap = config[pool].cap;
      if (supplyCap == 0) continue;

      pool.forceUpdateReserve(asset());

      uint256 supplyShares = pool.supplyShares(asset(), positionId);

      // `supplyAssets` needs to be rounded up for `toSupply` to be rounded down.
      (uint256 totalSupplyAssets, uint256 totalSupplyShares,,) = pool.marketBalances(asset());
@>    uint256 supplyAssets = supplyShares.toAssetsUp(totalSupplyAssets, totalSupplyShares);

@>    uint256 toSupply = UtilsLib.min(supplyCap.zeroFloorSub(supplyAssets), assets);

      if (toSupply > 0) {
        // Using try/catch to skip markets that revert.
        try pool.supplySimple(asset(), address(this), toSupply, 0) {
          assets -= toSupply;
        } catch {}
      }

      if (assets == 0) return;
    }

    if (assets != 0) revert CuratedErrorsLib.AllCapsReached();
  }

However, the function does not consider an underlying pool's supplyCap. Underlying pools have their own supplyCap which will cause supply() calls to revert if they would put the pool over it's supplyCap:

  function validateSupply(
    DataTypes.ReserveCache memory cache,
    DataTypes.ReserveData storage reserve,
    DataTypes.ExecuteSupplyParams memory params,
    DataTypes.ReserveSupplies storage totalSupplies
  ) internal view {
    ... SKIP!...

    uint256 supplyCap = cache.reserveConfiguration.getSupplyCap();

    require(
      supplyCap == 0
@>      || ((totalSupplies.supplyShares + uint256(reserve.accruedToTreasuryShares)).rayMul(cache.nextLiquidityIndex) + params.amount)
          <= supplyCap * (10 ** cache.reserveConfiguration.getDecimals()),
      PoolErrorsLib.SUPPLY_CAP_EXCEEDED
    );
  }

Also note that the pool::supplySimple() call in _supplyPool() is wrapped in a try/catch block, so if a pool::supplySimple() call were to revert, it will just continue to the next pool.

Internal pre-conditions

  • A CuratedVault::deposit() call needs to be within the limits of the curated vault's cap (config[pool].cap) but exceed the limits of the underlying pool's supplyCap.

External pre-conditions

No response

Attack Path

  1. A curated pool has two pools in the supplyQueue.
  2. The first underlying pool has an internal supplyCap of 100e18 and is currently at 99e18.
  3. The first underlying pool has an internal supplyCap of 100e18 and is currently at 99e18.
  4. A user calls deposit() on the curated vault with a value of 2e18.
  5. The value does not exceed the curated vault's config[pool].cap for either pool.
  6. The underlying call to Pool::supplySimple() will silently revert on both pools, and the entire transaction will revert due to running out of available pools to supply the assets to
  7. As a result, no assets are deposits, despite the underlying pools having capacity to accept the 2e18 deposit between them.

Impact

Deposit Reverts

  • If a deposit would be able to be deposited across two or more underlying pools in the supplyQueue, but is too large to be added to any one of these underlying pools, the deposit will completely revert, despite the underlying pools having capacity to accept the deposit.

Inefficient reorder of supplyQueue

  • If a deposit amount is within the limits of the curated pool's config[pool].cap, but would exceed the limits an underlying pool in supplyQueue. Then the silent revert would skip this pool and attempt to deposit it's liquidity to the next pool in the queue. This is an undesired/inefficient reordering of the supplyQueue as a simple check on the cap of the underlying pool would reveal some amount that would be accepted by the underlying pool.

PoC

No response

Mitigation

Create an external getter for a pool's supply cap, similar to ReserveConfiguration::getSupplyCap() the next function should also scale the supply cap by the reserve token's decimals.

Then, add an extra check in CuratedVaultSetters::_supplyPool() as shown below.

  function _supplyPool(uint256 assets) internal {
    for (uint256 i; i < supplyQueue.length; ++i) {
      IPool pool = supplyQueue[i];

      uint256 supplyCap = config[pool].cap;
      if (supplyCap == 0) continue;

      pool.forceUpdateReserve(asset());

      uint256 supplyShares = pool.supplyShares(asset(), positionId);

      // `supplyAssets` needs to be rounded up for `toSupply` to be rounded down.
      (uint256 totalSupplyAssets, uint256 totalSupplyShares,,) = pool.marketBalances(asset());
      uint256 supplyAssets = supplyShares.toAssetsUp(totalSupplyAssets, totalSupplyShares);

      uint256 toSupply = UtilsLib.min(supplyCap.zeroFloorSub(supplyAssets), assets);

+     toSupply = UtilsLib.min(toSupply, pool.getSupplyCap(pool.getConfiguration(asset())) - totalSupplyAssets );

      if (toSupply > 0) {
        // Using try/catch to skip markets that revert.
        try pool.supplySimple(asset(), address(this), toSupply, 0) {
          assets -= toSupply;
        } catch {}
      }

      if (assets == 0) return;
    }

    if (assets != 0) revert CuratedErrorsLib.AllCapsReached();
  }
@github-actions github-actions bot added the Duplicate A valid issue that is a duplicate of an issue with `Has Duplicates` label label Sep 20, 2024
@sherlock-admin3
Copy link
Contributor Author

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

Honour commented:

Invalid: see comment on #193

@sherlock-admin3 sherlock-admin3 changed the title Joyous Rainbow Shark - CuratedVaultSetters::_supplyPool() does not consider the pool cap of the underlying pool, which may cause deposit() to revert or lead to an unintended reordering of supplyQueue Nihavent - CuratedVaultSetters::_supplyPool() does not consider the pool cap of the underlying pool, which may cause deposit() to revert or lead to an unintended reordering of supplyQueue Oct 3, 2024
@sherlock-admin3 sherlock-admin3 added Non-Reward This issue will not receive a payout and removed Duplicate A valid issue that is a duplicate of an issue with `Has Duplicates` label labels Oct 3, 2024
@Nihavent
Copy link

Nihavent commented Oct 3, 2024

Escalate

This report should be valid and is not a duplicate of #399 which is about maxWithdraw and maxRedeem being non-ERC4626 compliant due to a bug in _withdrawable().
On the other hand, this report describes that _supplyPool() ignoring the underlying pool cap can result in unexpectedly reverting deposits or an inefficient reordering of the supplyQueue.

To address the comment left on #193 that was referenced on this report:

"setCap is an admin operation and It can be expected that the curators will set (there's no reason to assume otherwise) a similar pool(vault) cap as the underlying pool"

I believe there is a flaw in this reasoning.

  • The supplyCap stored in CuratedVaultStorage::config[pool].cap is the cap of deposits into a given underlying pool from this CuratedVault.
  • The underlying pool cap stored in the DataTypes.ReserveConfigurationMap for the underlying pool describes the total deposits to this underlying pool from all sources (including but not limited to this CuratedVault).

It's true the curator sets the supplyCap for the curated vault, however they are not in control of other deposts to the underlying pool. So, attempting to set them to similar values will only prevent this issue as long as there are no other deposits in the underlying pool, which is not a valid assumption for a curator to make.

Therefore both impacts in this report can occur regardless of the admin-set value.

@sherlock-admin3
Copy link
Contributor Author

sherlock-admin3 commented Oct 3, 2024

Escalate

This report should be valid and is not a duplicate of #399 which is about maxWithdraw and maxRedeem being non-ERC4626 compliant due to a bug in _withdrawable().
On the other hand, this report describes that _supplyPool() ignoring the underlying pool cap can result in unexpectedly reverting deposits or an inefficient reordering of the supplyQueue.

To address the comment left on #193 that was referenced on this report:

"setCap is an admin operation and It can be expected that the curators will set (there's no reason to assume otherwise) a similar pool(vault) cap as the underlying pool"

I believe there is a flaw in this reasoning.

  • The supplyCap stored in CuratedVaultStorage::config[pool].cap is the cap of deposits into a given underlying pool from this CuratedVault.
  • The underlying pool cap stored in the DataTypes.ReserveConfigurationMap for the underlying pool describes the total deposits to this underlying pool from all sources (including but not limited to this CuratedVault).

It's true the curator sets the supplyCap for the curated vault, however they are not in control of other deposts to the underlying pool. So, attempting to set them to similar values will only prevent this issue as long as there are no other deposits in the underlying pool, which is not a valid assumption for a curator to make.

Therefore both impacts in this report can occur regardless of the admin-set value.

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.

@cvetanovv
Copy link

@Nihavent The impact is very similar to issues #337 and #431.

You can see this comment: #337 (comment)

I see no reason for it to be any different here.

@Nihavent
Copy link

Nihavent commented Oct 9, 2024

@cvetanovv the root cause is similar to the issues you referenced but the impact here is higher.

The maximum impact on the issues you linked is non compliance with the EIP which can break external integrations, as you mentioned this does not necessarily qualify as medium impact.

The maximum impact on this issue is reverting deposits when the pools have capacity to support the deposit (see attack path). This is an implementation bug which is broken contract functionality.
Additionally, assets can be allocated to underlying pools in an order which differs from the depositQueue.

This issue has nothing to do with EIP compliance.

For reference this issue has the same root cause and impact as sherlock-audit/2024-08-sentiment-v2-judging#178

@cvetanovv
Copy link

@Nihavent I agree that in this issue, the impact is one idea more serious, and we enter the category "broken contract functionality".

Planning to accept the escalation and remove the duplication with #399. I will duplicate this issue(#433) with #193 and #339. This issue will be the main.

@0xjuaan
Copy link

0xjuaan commented Oct 9, 2024

Hi @cvetanovv, please consider the following judging comment regarding why this issue is invalid.

Vault curators should not set a cap that is greater than the cap of underlying pools. It makes no sense to do so. For example if the underlying pool allows a max of 10e18, then vault curators should set a deposit cap that is less than or equal to 10e18. This issue requires vault curators to set a cap that is higher than the underlying pool's cap, so is invalid.

@Nihavent
Copy link

Nihavent commented Oct 9, 2024

Hi @cvetanovv, please consider the following judging comment regarding why this issue is invalid.

Vault curators should not set a cap that is greater than the cap of underlying pools. It makes no sense to do so. For example if the underlying pool allows a max of 10e18, then vault curators should set a deposit cap that is less than or equal to 10e18. This issue requires vault curators to set a cap that is higher than the underlying pool's cap, so is invalid.

This is not true as I explained here #433 (comment)

Imagine the super pool has 2 underlying pools each with an underlying cap of 10e18. The SuperPool admin sets their caps to 10e18 as you said. Each underlying pool receives deposits from other sources to the value of 9e18. Now a user trying to deposit 2e18 into the SuperPool will revert even though this deposit could be split across the two underlying pools.

Therefore carefully setting the SuperPool cap for a given pool is only an effective mitigation if there are 0 deposits from other sources in the underlying pool. As I previously mentioned this is an unrealistic assumption for anyone to make.

”This issue requires vault curators to set a cap that is higher than the underlying pool's cap, so is invalid.”

This is also incorrect because in the above example the SuperPool cap set for the two pools could be 5e18 and the issue still exists.

I believe this is straightforward but if you require a POC showing the issue when the SuperPool cap is < the underlying pool cap I can write one tomorrow.

@0xjuaan
Copy link

0xjuaan commented Oct 9, 2024

Yeah actually you're right @Nihavent, the issue can still occur. I still think that the issue would not exist if vault curators set an appropriate cap, and that is why Morpho decided to implement it this way. In the case that an event like the described one occurs, the curators can reduce the cap of the pool such that only 1e18 can be deposited into each pool, and this prevents the revert.

That is the reason why submitCap() has no timelock for reducing a pool cap, but does have a timelock for increasing the pool cap. It's because reducing pool caps should be able to happen to prevent the described issue.

@Nihavent
Copy link

Nihavent commented Oct 9, 2024

Edit: Updated this comment with a more considered and complete response.

@0xjuaan thanks for acknowledging the example I provided which shows the issue is possible regardless of carefuly set admin values. I do concede this issue is less likely to occur (but not impossible) if the admin continuously sets conservatively low SuperPool caps for the underlying pools (relative to available caps in the underlying pools). However, I will now explain why I don't believe the SuperPool cap should or would ever be used for this purpose.

There are two relevant caps we're discussing:

  1. The SuperPool cap which is the maximum allocation that a SuperPool will allocate to a given underlying pool, and
  2. The underlying pool's cap which is the maximum liquidity from all sources that can be deposited into the pool

We can make inferences from the fact that these two caps exist:

  • The SuperPool cap (1) is set by curators to represent the ideal maximum allocation to an underlying pool. This is one of the risk management tools at the disposal of the SuperPool admins.
  • If the intended use case of the SuperPool cap (1) was to always have it set to the available cap in the underling pool (2), this could have been achieved programatically and in fact the parameter (1) wouldn't exist.

This comment suggests that a partial mitigation to this issue is to burden SuperPool curators with administrative work of continuously checking for a reduction in available cap in all underlying pools, and reducing the corresponding SuperPool cap to reflect this change. The evidence given for this is there is no timelock on reduction in superPool caps. I believe there are problems with this:

  1. It completely reduces the purpose of the SuperPool cap from a risk management/capital allocation tool to an administrative task for curators to update (in order to prevent edge-case deposit reverts).
  2. We can reasonably expect that curators would not update the cap in this way because:
    • The effort / benefits tradeoff doesn't make sense fo them to use the parameter in this way. They would care more about achieving their ideal capital allocation than preventing deposit reverts into the SuperPool.
    • It is a waste of gas to update it potentially several times per day per underlying pool
    • It is logistically impractical to poll for reductions in available caps across all underlying pools, they would need a bot to do this and then backrun underlying pool supply() calls with submitCap() calls.
    • Whilst the cap can be reduced without a timelock, it requires a timelock to increase. Therefore if they reduced it to protect against this issue, and then there is a withdrawal from the underlying pool, they can't increase the cap again quickly. Therefore, their pool loses the chance to deposit more liquidity and a different SuperPool that wasn't continuously reducing their cap gets to deposit this liquidity instead.

So whilst it’s technically possible to reduce the likelihood of this issue with admin actions, it is unreasonable to expect the admin to use the parameter for this purpose.

@Honour-d-dev
Copy link

Honour-d-dev commented Oct 11, 2024

I also believe this issue is not worth a medium severity, submitCap() is an admin operation and it has been shown that the protocol is designed in such a way that its easy for the admin to handle this if it ever happens (and the chances of it happening are vey slim if the admin sets a reasonable cap to begin with)

@Nihavent
Copy link

I also believe this issue is not worth a medium severity, submitCap() is an admin operation and it has been shown that the protocol is designed in such a way that its easy for the admin to handle this if it ever happens (and the chances of it happening are vey slim if the admin sets a reasonable cap to begin with)

I agree the issue is not going to occur frequently due to it being a bug that occurs in edge cases. To my knowledge the likelihood of the bug does not play a role in Sherlock’s judging rules.

Check my previous comment for an explanation as to why setCap would need to be misused to reduce the likihood of this bug.

It has also been shown that this issue can occur regardless of admin set values.

@cvetanovv
Copy link

I believe this issue should be Low severity.

While it could lead to some deposit reverts or inefficiencies in the supplyQueue, it's important to note that this is an admin-controlled action. Admins have the ability to set reasonable caps and monitor pool configurations effectively to prevent such situations.

Additionally, there is no direct loss of funds.

For these reasons, I believe that this issue is of low severity.

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

@Nihavent
Copy link

Nihavent commented Oct 22, 2024

Hi, thanks for consideration of the issue.

It has been shown that this issue can occur regardless of the most reasonable admin setCap value. This is because the available liquidity in underlying pools can change quickly.

For this parameter to be used in a way to almost completely avoid this issue, the cap would need to be set extremely low which defeats the purpose of the pool being in the supply queue.

Additionally, as I explained in detail here the setCap functionality should not and will not be used to reduce the likelihood of this issue.
Requiring curators to setCap to avoid this issue costs them the functionality of setting their optimal liquidity allocation to each pool, ie. they lose the intended functionality of the parameter in order to prevent edge-case deposit reverts. Therefore no rational actor would use the parameter in this way.

If the protocol didn’t want curators to be able to freely set caps, the parameter wouldn’t exist and vaults would inherit the available caps from the underlying pools.

As it stands, with admins freely setting their optimal caps as per the design, there is a bug in the implementation.

@Honour-d-dev
Copy link

This issue is a low severity

There is no loss of funds or broken functionality or any substantial impact, the only issue here is a short/temporary inconvenience for the user that can be immediately and easily fixed by admin adjusting the cap or reordering the queue if necessary

@cvetanovv
Copy link

This issue is а valid Low severity.

Here are the rules for Medium severity:
https://docs.sherlock.xyz/audits/real-time-judging/judging#v.-how-to-identify-a-medium-issue

  1. Causes a loss of funds but requires certain external conditions or specific states, or a loss is highly constrained. The loss of the affected party must exceed 0.01% and 10 USD.
  2. 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.

In this issue, there is neither loss of funds nor broken functionality.

My decision to reject the escalation remains.

@Nihavent
Copy link

If this issue does not have enough impact for medium severity, how is this issue a valid medium? sherlock-audit/2024-08-sentiment-v2-judging#178

I understand that reports will be judged on a case-by-case basis, but there is no meaningful difference between the protocols with respect to this issue, and the submitted issues are the same. Therefore for consistency if there is enough impact in that issue for medium, there must be enough impact in this issue for medium.
How are Watsons able to gauge which issues consistitue a medium if this level of consistency is not present?

  • Both issues have a vault which sometimes fails to deposit into underlying pools in the queue because the available liquidity in the underlying pool is not checked.
  • Both issues can result in deposit reverts or undesired reorderings of the queue.
  • Both issues have an admin-set cap for the vault's maximum deposit into the underlying pool
  • Both issues have the same pre-conditions
  • Both issues have the same fix

@cvetanovv
Copy link

@Nihavent You are right about that. As you can see in the comments, I was hesitant about having broken functionality. After the protocol comment, then I decided it was Medium because it was important for them to have the function not revert.

In this contract(CuratedVaultSetters.sol), we also have indications that the function should not revert, so I think we have broken functionality here also: https://github.com/sherlock-audit/2024-06-new-scope/blob/main/zerolend-one/contracts/core/vaults/CuratedVaultSetters.sol#L133

Planning to accept the escalation and make this issue Medium.

@0xSpearmint
Copy link

Hi @cvetanovv 193 is also a duplicate of this issue.

@cvetanovv
Copy link

Hi @cvetanovv 193 is also a duplicate of this issue.

Thanks for the mention. I will also duplicate #193 to this issue.

@WangSecurity WangSecurity added Has Duplicates A valid issue with 1+ other issues describing the same vulnerability Medium A Medium severity issue. labels Oct 26, 2024
@sherlock-admin2 sherlock-admin2 removed the Non-Reward This issue will not receive a payout label Oct 26, 2024
@WangSecurity
Copy link

WangSecurity commented Oct 26, 2024

Result:
Medium
Has duplicates

@WangSecurity WangSecurity reopened this Oct 26, 2024
@sherlock-admin2
Copy link
Contributor

sherlock-admin2 commented Oct 26, 2024

Escalations have been resolved successfully!

Escalation status:

@sherlock-admin3 sherlock-admin3 removed the Escalated This issue contains a pending escalation label Oct 26, 2024
@sherlock-admin4 sherlock-admin4 added the Escalation Resolved This issue's escalations have been approved/rejected label Oct 26, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Escalation Resolved This issue's escalations have been approved/rejected Has Duplicates A valid issue with 1+ other issues describing the same vulnerability Medium A Medium severity issue. Reward A payout will be made for this issue
Projects
None yet
Development

No branches or pull requests

9 participants