Contests
Active
Upcoming
Juging Contests
Escalations Open
Sherlock Judging
Finished
Active
Upcoming
Juging Contests
Escalations Open
Sherlock Judging
Finished
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/161
Aymen0909, Bahurum, 0xlmanini, ABA, Met, carrot, chaduke, nobody2018, GimelSec, Bauer, KingNFT, cducrest-brainbot, 0x52, rvierdiiev
The userRewardDebt
s array stores the users debt to 36 dp but in _claimInternalRewards
and _claimExternalRewards
the 18 dp reward token amount. The result is that usersRewardDebts
incorrectly tracks how many rewards have been claimed and would allow an adversary to claim repeatedly and drain the entire reward balance of the contract.
When calculating the total rewards owed a user it subtracts userRewardDebts
from lpPositions[user_] * accumulatedRewardsPerShare
. Since lpPositions[user_]
and accumulatedRewardsPerShare
are both 18 dp values, this means that userRewardDebts
should store the debt to 36 dp.
In _depositUpdateRewardDebts
we can see that userRewardDebts
is in fact stored as a 36 dp value because lpReceived_
and rewardToken.accumulatedRewardsPerShare
are both 18 dp values.
When claiming tokens, userRewardDebts
is updated with the raw 18 dp reward
amount NOT a 36 dp value like it should. The result is that userRewardDebts
is incremented by a fraction of what it should be. Since it isn't updated correctly, subsequent claims will give the user too many tokens. An malicious user could abuse this to repeatedly call the contract and drain it of all reward tokens.
Contract will send to many reward tokens and will be drained
ChatGPT
Scale the reward
amount by 1e18:
uint256 fee = (reward * FEE) / PRECISION;
- userRewardDebts[msg.sender][rewardToken.token] += reward;
+ userRewardDebts[msg.sender][rewardToken.token] += reward * 1e18;
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/110
Bahurum, Bobface, KingNFT, cducrest-brainbot, 0x52, immeas
Adversary can profit off of the single sided liquidity vault by depositing, buying OHM, withdrawing then dumping the profited OHM. This attack remains profitable regardless of the value of THRESHOLD
.
SingleSidedLiquidityVault#deposit allows a user to specify the amount of wstETH they wish to deposit into the vault. The vault then mints the proper amount of OHM to match this, then deposits both into the wstETH/OHM liquidity pool on Balancer. If the price of OHM changes between deposit and withdrawal, the vault will effectively eat the IL caused by the movement. If the price decreases then the vault will burn more OHM than minted. If the price increases then the vault will burn less OHM than minted. This discrepancy can be exploited by malicious users to profit at the expense of the vault.
First we will outline the flow of the attack then run through the numbers:
Now we can crunch the numbers to prove that this is profitable:
The only assumption we need to make is the price of OHM/wstETH which for simplicity we will assume is 1:1.
Balances before attack:
Liquidity: 80 OHM 80 wstETH
Adversary: 20 wstETH
Balances after adversary has deposited to the pool:
Liquidity: 100 OHM 100 wstETH
Adversary: 0 wstETH
Balances after adversary sells wstETH for OHM (1% movement in price):
Liquidity: 99.503 OHM 100.498 wstETH
Adversary: 0.496 OHM -0.498 wstETH
Balances after adversary removes their liquidity:
Liquidity: 79.602 OHM 80.399 wstETH
Adversary: 0.496 OHM 19.7 wstETH
Balances after selling profited OHM:
Liquidity: 80.099 OHM 79.9 wstETH
Adversary: 20.099 wstETH
We can see that the adversary will gain wstETH for each time they loop this through attack. The profit being made i For simplicity I have only walked through a single direction attack but the adversary could easily drop the price to the lower threshold then start the attack to gain a larger amount of wstETH.
No matter how tight the threshold is set it is impossible to make this kind of attack unprofitable. Tighter thresholds just increases the amount of capital required to make it profitable. Another issue is that the THRESHOLD value can only get so small before the it starts causing random reverts for legitimate users.
For additional context, the fee charged by the pool only slightly impacts the profitability of this attack. Since the attacker only needs to manipulate the price within the threshold, fees scale linearly with THRESHOLD and therefore don't change the profitability of the attack.
Vault can be exploited for a nearly unlimited amount of OHM
ChatGPT
The only mechanism I can think of to prevent this is to add a withdraw/deposit fee to the vault
unbanksy
The auditor incorrectly assumes that the user receives OHM on withdraw:
Balances after adversary sells wstETH for OHM (1% movement in price):
Liquidity: 99.503 OHM 100.498 wstETH
Adversary: 0.496 OHM -0.498 wstETH
That is not the case as the OHM is burned by the protocol. @0xLienid right?
0xLienid
@unbanksy I don't think that's the assumption the auditor is making. Based on their math it seems they recognize that the user only gets the wstETH portion back based on these steps:
Balances after adversary sells wstETH for OHM (1% movement in price):
Liquidity: 99.503 OHM 100.498 wstETH
Adversary: 0.496 OHM -0.498 wstETH
Balances after adversary removes their liquidity:
Liquidity: 79.602 OHM 80.399 wstETH
Adversary: 0.496 OHM 19.7 wstETH
I think the "Balances after adversary removes their liquidity" step might be wrong and the adversary should end up with 19.6016 wstETH which would make this not really profitable.
IAm0x52
@0xLienid The 19.7 is a typo. When they withdraw they get 20.0996 which makes their net 19.6016. So it should read 19.6 at that step not 19.7. When the user sells their OHM they net 0.499 stETH so the final balance is correct at 20.099 (19.6+0.499) and the attack is profitable.
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/43
CRYP70, ABA, ast3ros, nobody2018, minhtrng, saian, jonatascm, KingNFT, cducrest-brainbot, Ruhum, rvierdiiev
cachedUserRewards variable is never reset, so user can steal all rewards
When user wants to withdraw then _withdrawUpdateRewardState
function is called.
This function updates internal reward state and claims rewards for user if he provided true
as claim_
param.
In case if user didn't want to claim, and rewardDebtDiff > userRewardDebts[msg.sender][rewardToken.token]
then cachedUserRewards
variable will be set for him which will allow him to claim that amount later.
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L583-L590
if (rewardDebtDiff > userRewardDebts[msg.sender][rewardToken.token]) { userRewardDebts[msg.sender][rewardToken.token] = 0; cachedUserRewards[msg.sender][rewardToken.token] += rewardDebtDiff - userRewardDebts[msg.sender][rewardToken.token]; } else { userRewardDebts[msg.sender][rewardToken.token] -= rewardDebtDiff; }
When user calls claimRewards, then cachedUserRewards
variable is added to the rewards he should receive.
The problem is that cachedUserRewards
variable is never reset to 0, once user claimed that amount.
Because of that he can claim multiple times in order to receive all balance of token.
User can steal all rewards
Provided above
Manual Review
Once user received rewards, reset cachedUserRewards
variable to 0. This can be done inside _claimInternalRewards
function.
0xLienid
This should be high severity
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/13
joestakey, cccz, usmannk, Bahurum, Dug, ABA, psy4n0n, chaduke, carrot, minhtrng, jonatascm, GimelSec, ak1, RaymondFam, Ruhum, rvierdiiev
In the withdraw()
function of the SingleSidedLiquidityVault the contract updates the reward state. Because of a mistake in the calculation, the user is assigned more rewards than they're supposed to.
When a user withdraws their funds, the _withdrawUpdateRewardState()
function checks how many rewards those LP shares generated. If that amount is higher than the actual amount of reward tokens that the user claimed, the difference between those values is cached and the amount the user claimed is set to 0. That way they receive the remaining shares the next time they claim.
But, the contract resets the number of reward tokens the user claimed before it computes the difference. That way, the full amount of reward tokens the LP shares generated are added to the cache.
Here's an example:
_withdrawUpdateRewardState()
with lpAmount_ = 5e17
and claim = false
:function _withdrawUpdateRewardState(uint256 lpAmount_, bool claim_) internal { uint256 numInternalRewardTokens = internalRewardTokens.length; uint256 numExternalRewardTokens = externalRewardTokens.length; // Handles accounting logic for internal and external rewards, harvests external rewards uint256[] memory accumulatedInternalRewards = _accumulateInternalRewards(); uint256[] memory accumulatedExternalRewards = _accumulateExternalRewards(); for (uint256 i; i < numInternalRewardTokens;) { _updateInternalRewardState(i, accumulatedInternalRewards[i]); if (claim_) _claimInternalRewards(i); // Update reward debts so as to not understate the amount of rewards owed to the user, and push // any unclaimed rewards to the user's reward debt so that they can be claimed later InternalRewardToken memory rewardToken = internalRewardTokens[i]; // @audit In our example, rewardDebtDiff = 3e17 (total rewards are 6e17 so 50% of shares earned 50% of reward tokens) uint256 rewardDebtDiff = lpAmount_ * rewardToken.accumulatedRewardsPerShare; // @audit 3e17 > 1e17 if (rewardDebtDiff > userRewardDebts[msg.sender][rewardToken.token]) { // @audit userRewardDebts is set to 0 (original value was 1e17, the number of tokens that were already claimed) userRewardDebts[msg.sender][rewardToken.token] = 0; // @audit cached amount = 3e17 - 0 = 3e17. // Alice is assigned 3e17 reward tokens to be distributed the next time they claim // The remaining 3e17 LP shares are worth another 3e17 reward tokens. // Alice already claimed 1e17 before the withdrawal. // Thus, Alice receives 7e17 reward tokens instead of 6e17 cachedUserRewards[msg.sender][rewardToken.token] += rewardDebtDiff - userRewardDebts[msg.sender][rewardToken.token]; } else { userRewardDebts[msg.sender][rewardToken.token] -= rewardDebtDiff; } unchecked { ++i; } }
A user can receive more reward tokens than they should by abusing the withdrawal system.
The issue is that userRewardDebts
is set to 0
before it's used in the calculation of cachedUserRewards
: https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L566-L619
function _withdrawUpdateRewardState(uint256 lpAmount_, bool claim_) internal { uint256 numInternalRewardTokens = internalRewardTokens.length; uint256 numExternalRewardTokens = externalRewardTokens.length; // Handles accounting logic for internal and external rewards, harvests external rewards uint256[] memory accumulatedInternalRewards = _accumulateInternalRewards(); uint256[] memory accumulatedExternalRewards = _accumulateExternalRewards(); for (uint256 i; i < numInternalRewardTokens; ) { _updateInternalRewardState(i, accumulatedInternalRewards[i]); if (claim_) _claimInternalRewards(i); // Update reward debts so as to not understate the amount of rewards owed to the user, and push // any unclaimed rewards to the user's reward debt so that they can be claimed later InternalRewardToken memory rewardToken = internalRewardTokens[i]; uint256 rewardDebtDiff = lpAmount_ * rewardToken.accumulatedRewardsPerShare; if (rewardDebtDiff > userRewardDebts[msg.sender][rewardToken.token]) { userRewardDebts[msg.sender][rewardToken.token] = 0; cachedUserRewards[msg.sender][rewardToken.token] += rewardDebtDiff - userRewardDebts[msg.sender][rewardToken.token]; } else { userRewardDebts[msg.sender][rewardToken.token] -= rewardDebtDiff; } unchecked { ++i; } } for (uint256 i; i < numExternalRewardTokens; ) { _updateExternalRewardState(i, accumulatedExternalRewards[i]); if (claim_) _claimExternalRewards(i); // Update reward debts so as to not understate the amount of rewards owed to the user, and push // any unclaimed rewards to the user's reward debt so that they can be claimed later ExternalRewardToken memory rewardToken = externalRewardTokens[i]; uint256 rewardDebtDiff = lpAmount_ * rewardToken.accumulatedRewardsPerShare; if (rewardDebtDiff > userRewardDebts[msg.sender][rewardToken.token]) { userRewardDebts[msg.sender][rewardToken.token] = 0; cachedUserRewards[msg.sender][rewardToken.token] += rewardDebtDiff - userRewardDebts[msg.sender][rewardToken.token]; } else { userRewardDebts[msg.sender][rewardToken.token] -= rewardDebtDiff; } unchecked { ++i; } } }
Manual Review
First calculate cachedUserRewards
then reset userRewardDebts
.
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/222
0x52
SingleSidedLiquidityVault allows the admin tokens from the vault contract. This can only be done once the vault has been deactivated but there is nothing stopping the contract from being reactivated after a token has been rescued. If an external reward token is rescued then the token accounting will be permanently broken after when/if the vault is re-enabled.
See summary.
External reward tokens are broken after being rescued
ChatGPT
If the token being rescued is an external reward token then rescueToken should update rewardToken.lastBalance
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/210
Bahurum
The chainlink price could stay up to 24 hours (heartbeat period) outside the boundaries defined by THRESHOLD
but within the chainlink deviation threshold. Deposits and withdrawals will not be possible during this period of time.
The _isPoolSafe()
function checks if the balancer pool spot price is within the boundaries defined by THRESHOLD
respect to the last fetched chainlink price.
Since in _valueCollateral()
the updateThreshold
should be 24 hours (as in the tests), then the OHM derived oracle price could stay at up to 2% from the on-chain trusted price. The value is 2% because in WstethLiquidityVault.sol#L223:
return (amount_ * stethPerWsteth * stethUsd * decimalAdjustment) / (ohmEth * ethUsd * 1e18);
stethPerWsteth
is mostly stable and changes in stethUsd
and ethUsd
will cancel out, so the return value changes will be close to changes in ohmEth
, so up to 2% from the on-chain trusted price.
If THRESHOLD
< 2%, say 1% as in the tests, then the Chainlink price can deviate by more than 1% from the pool spot price and less than 2% from the on-chain trusted price fro up to 24 h. During this period withdrawals and deposits will revert.
Withdrawals and deposits can be often unavailable for several hours.
Manual Review
THRESHOLD
is not fixed and can be changed by the admin, meaning that it can take different values over time.Only a tight range of values around 2% should be allowed to avoid the scenario above.
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/187
cccz, mahdikarimi, ABA, xAlismx, GimelSec, Ruhum
When a user claims some cached rewards it's possible that rewards be freezed for a while .
the following line in internalRewardsForToken function can revert because already claimed rewards has been added to debt so if amount of debt be higher than accumulated rewards for user LP shares it will revert before counting cached rewards value so user should wait until earned rewards as much as last time he/she claimed rewards to be able claim it .
uint256 totalAccumulatedRewards = (lpPositions[user_] * accumulatedRewardsPerShare) - userRewardDebts[user_][rewardToken.token];
user rewards will be locked for a while
Manual Review
add cached rewards to total rewards like the following line
uint256 totalAccumulatedRewards = (lpPositions[user_] * accumulatedRewardsPerShare + cachedUserRewards[user_][rewardToken.token] ) - userRewardDebts[user_][rewardToken.token];
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/177
cccz, cducrest-brainbot, 0x52, hansfriese
Once reward tokens are removed they can never be added back to the contract. The happens because accumulated rewards are tracked differently globally vs individually. Global accumulated rewards are tracked inside the rewardToken array whereas it is tracked by token address for users. When a reward token is removed the global tracker is cleared but the individual trackers are not. If a removed token is added again, the global tracker will reset to zero but the individual tracker won't. As a result of this claiming will fail due to an underflow.
The amount of accumulated rewards for a specific token is tracked in it's respective rewardToken struct.
For individual users the rewards are stored in a mapping.
When a reward token is removed the global tracker for the accumulated rewards is also removed. The problem is that the individual mapping still stores the previously accumulated rewards. If the token is ever added again, the global accumulated reward tracker will now be reset but the individual trackers will not. This will cause an underflow anytime a user tries to claim reward tokens.
Reward tokens cannot be added again once they are removed
ChatGPT
Consider tracking accumulatedRewardsPerShare in a mapping rather than in the individual struct or change how removal of reward tokens works
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/128
tives, Bahurum, 0xlmanini, minhtrng, 0x52
Internal reward tokens accrue indefinitely with no way to change the amount that they accrue each block (besides removing them which has other issues) or input a timestamp that they stop accruing. Additionally there is no check that the contract has enough tokens to fund the rewards that it has committed to. As a result of this the contract may over commit reward tokens and after the token balance of the contract has been exhausted, all further claims will fail.
Internal reward tokens are added with a fixed _rewardPerSecond that will accrue indefinitely because it does not have an ending timestamp. As a result the contract won't stop accruing internal rewards even if it has already designated it's entire token balance. After it has over committed it will now be impossible for all users to claim their balance. Additionally claiming rewards is an all or nothing function meaning that once a single reward token starts reverting, it becomes impossible to claim any rewards at all.
Internal reward tokens can over commit and break claiming of all reward tokens
ChatGPT
I recommend adding an end timestamp to the accrual of internal tokens. Additionally, the amount of tokens needed to fund the internal tokens should be transferred from the caller (or otherwise tracked) when the token is added.
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/127
Cryptor, CRYP70, kiki_dev, Bauer, hansfriese, HonorLt, gerdusx, KingNFT, 0x52, Ruhum, rvierdiiev
When a reward token is removed, it's entire reward structs is deleted from the reward token array. The results is that after it has been removed it is impossible to claim. User's who haven't claimed will permanently lose all their unclaimed rewards.
When a reward token is removed the entire reward token struct is deleted from the array
When claiming rewards it cycles through the current reward token array and claims each token. As a result of this, after a reward token has been removed it becomes impossible to claim. Any unclaimed balance that a user had will be permanently lost.
Submitting this as high because the way that internal tokens are accrued (see "Internal reward tokens can and likely will over commit rewards") will force this issue and therefore loss of funds to users to happen.
Users will lose all unclaimed rewards when a reward token is removed
ChatGPT
When a reward token is removed it should be moved into a "claim only" mode. In this state rewards will no longer accrue but all outstanding balances will still be claimable.
_accumulateExternalRewards()
could turn into an infinite loop if the check condition is trueSource: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/125
RaymondFam, shark
In WstethLiquidityVault.sol, the for loop in _accumulateExternalRewards()
utilizes continue
so it could proceed to the next iteration upon having a true condition in the sanity check. This will however turn the function into an infinite loop because ++i
has been included at the end of the loop logic. As a result, this skipped increment leads to the same externalRewardTokens[i]
repeatedly assigned to rewardToken
where newBalance < rewardToken.lastBalance
continues to equal true until the same executions make the gas run out.
Here is a typical scenario:
_accumulateExternalRewards()
gets invoked via one of the functions embedding it, i.e. claimRewards()
, _depositUpdateRewardState()
or _withdrawUpdateRewardState()
of SingleSidedLiquidityVault.sol.newBalance < rewardToken.lastBalance
returns true for a specific reward token.continue
comes before ++i
, this non-incremented iteration is repeatedly executed till gas is run out.This will persistently cause DOS on _accumulateExternalRewards()
for all function calls dependent on it. Depending on how big the deficiency is, the situation can only be remedied by:
deactivate()
is called.Note: The situation could be worse if more than 1 elements in the array ExternalRewardToken[]
were similarly affected.
File: WstethLiquidityVault.sol#L192-L216
function _accumulateExternalRewards() internal override returns (uint256[] memory) { uint256 numExternalRewards = externalRewardTokens.length; auraPool.rewardsPool.getReward(address(this), true); uint256[] memory rewards = new uint256[](numExternalRewards); for (uint256 i; i < numExternalRewards; ) { ExternalRewardToken storage rewardToken = externalRewardTokens[i]; uint256 newBalance = ERC20(rewardToken.token).balanceOf(address(this)); // This shouldn't happen but adding a sanity check in case if (newBalance < rewardToken.lastBalance) { emit LiquidityVault_ExternalAccumulationError(rewardToken.token); continue; } rewards[i] = newBalance - rewardToken.lastBalance; rewardToken.lastBalance = newBalance; unchecked { ++i; } } return rewards; }
Manual Review
Consider having the affected code logic refactored as follows:
function _accumulateExternalRewards() internal override returns (uint256[] memory) { uint256 numExternalRewards = externalRewardTokens.length; auraPool.rewardsPool.getReward(address(this), true); uint256[] memory rewards = new uint256[](numExternalRewards); + unchecked { - for (uint256 i; i < numExternalRewards; ) { + for (uint256 i; i < numExternalRewards; ++i;) { ExternalRewardToken storage rewardToken = externalRewardTokens[i]; uint256 newBalance = ERC20(rewardToken.token).balanceOf(address(this)); // This shouldn't happen but adding a sanity check in case if (newBalance < rewardToken.lastBalance) { emit LiquidityVault_ExternalAccumulationError(rewardToken.token); continue; } rewards[i] = newBalance - rewardToken.lastBalance; rewardToken.lastBalance = newBalance; - unchecked { - ++i; - } } + } return rewards; }
This will safely increment i
when continue
is hit and move on to the next i + 1
iteration while still having SafeMath unchecked for the entire scope of the for loop.
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/102
joestakey, cccz, psy4n0n, Bobface, jonatascm, immeas, favelanky, rvierdiiev
SingleSidedLiquidityVault.withdraw will decreases ohmMinted, which will make the calculation involving ohmMinted incorrect.
In SingleSidedLiquidityVault, ohmMinted indicates the number of ohm minted in the contract, and ohmRemoved indicates the number of ohm burned in the contract.
So the contract just needs to increase ohmMinted in deposit() and increase ohmRemoved in withdraw().
But withdraw() decreases ohmMinted, which makes the calculation involving ohmMinted incorrect.
ohmMinted -= ohmReceived > ohmMinted ? ohmMinted : ohmReceived; ohmRemoved += ohmReceived > ohmMinted ? ohmReceived - ohmMinted : 0;
Consider that a user minted 100 ohm in deposit() and immediately burned 100 ohm in withdraw().
In _canDeposit, the amount_ is less than LIMIT + 1000 instead of LIMIT
function _canDeposit(uint256 amount_) internal view virtual returns (bool) { if (amount_ + ohmMinted > LIMIT + ohmRemoved) revert LiquidityVault_LimitViolation(); return true; }
getOhmEmissions() returns 1000 instead of 0
function getOhmEmissions() external view returns (uint256 emitted, uint256 removed) { uint256 currentPoolOhmShare = _getPoolOhmShare(); if (ohmMinted > currentPoolOhmShare + ohmRemoved) emitted = ohmMinted - currentPoolOhmShare - ohmRemoved; else removed = currentPoolOhmShare + ohmRemoved - ohmMinted; }
It will make the calculation involving ohmMinted incorrect.
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L276-L277
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L392-L409
Manual Review
function withdraw( uint256 lpAmount_, uint256[] calldata minTokenAmounts_, bool claim_ ) external onlyWhileActive nonReentrant returns (uint256) { // Liquidity vaults should always be built around a two token pool so we can assume // the array will always have two elements if (lpAmount_ == 0 || minTokenAmounts_[0] == 0 || minTokenAmounts_[1] == 0) revert LiquidityVault_InvalidParams(); if (!_isPoolSafe()) revert LiquidityVault_PoolImbalanced(); _withdrawUpdateRewardState(lpAmount_, claim_); totalLP -= lpAmount_; lpPositions[msg.sender] -= lpAmount_; // Withdraw OHM and pairToken from LP (uint256 ohmReceived, uint256 pairTokenReceived) = _withdraw(lpAmount_, minTokenAmounts_); // Reduce deposit values uint256 userDeposit = pairTokenDeposits[msg.sender]; pairTokenDeposits[msg.sender] -= pairTokenReceived > userDeposit ? userDeposit : pairTokenReceived; - ohmMinted -= ohmReceived > ohmMinted ? ohmMinted : ohmReceived; ohmRemoved += ohmReceived > ohmMinted ? ohmReceived - ohmMinted : 0;
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/100
cccz
claimFees will update rewardToken.lastBalance so that if there are unaccrued reward tokens in the contract, users will not be able to claim them.
_accumulateExternalRewards takes the difference between the contract's reward token balance and lastBalance as the reward.
and the accumulated reward tokens are updated by _updateExternalRewardState.
function _accumulateExternalRewards() internal override returns (uint256[] memory) { uint256 numExternalRewards = externalRewardTokens.length; auraPool.rewardsPool.getReward(address(this), true); uint256[] memory rewards = new uint256[](numExternalRewards); for (uint256 i; i < numExternalRewards; ) { ExternalRewardToken storage rewardToken = externalRewardTokens[i]; uint256 newBalance = ERC20(rewardToken.token).balanceOf(address(this)); // This shouldn't happen but adding a sanity check in case if (newBalance < rewardToken.lastBalance) { emit LiquidityVault_ExternalAccumulationError(rewardToken.token); continue; } rewards[i] = newBalance - rewardToken.lastBalance; rewardToken.lastBalance = newBalance; unchecked { ++i; } } return rewards; } ... function _updateExternalRewardState(uint256 id_, uint256 amountAccumulated_) internal { // This correctly uses 1e18 because the LP tokens of all major DEXs have 18 decimals if (totalLP != 0) externalRewardTokens[id_].accumulatedRewardsPerShare += (amountAccumulated_ * 1e18) / totalLP; }
auraPool.rewardsPool.getReward can be called by anyone to send the reward tokens to the contract
function getReward(address _account, bool _claimExtras) public updateReward(_account) returns(bool){ uint256 reward = earned(_account); if (reward > 0) { rewards[_account] = 0; rewardToken.safeTransfer(_account, reward); IDeposit(operator).rewardClaimed(pid, _account, reward); emit RewardPaid(_account, reward); } //also get rewards from linked rewards if(_claimExtras){ for(uint i=0; i < extraRewards.length; i++){ IRewards(extraRewards[i]).getReward(_account); } } return true; }
However, in claimFees, the rewardToken.lastBalance will be updated to the current contract balance after the admin has claimed the fees.
function claimFees() external onlyRole("liquidityvault_admin") { uint256 numInternalRewardTokens = internalRewardTokens.length; uint256 numExternalRewardTokens = externalRewardTokens.length; for (uint256 i; i < numInternalRewardTokens; ) { address rewardToken = internalRewardTokens[i].token; uint256 feeToSend = accumulatedFees[rewardToken]; accumulatedFees[rewardToken] = 0; ERC20(rewardToken).safeTransfer(msg.sender, feeToSend); unchecked { ++i; } } for (uint256 i; i < numExternalRewardTokens; ) { ExternalRewardToken storage rewardToken = externalRewardTokens[i]; uint256 feeToSend = accumulatedFees[rewardToken.token]; accumulatedFees[rewardToken.token] = 0; ERC20(rewardToken.token).safeTransfer(msg.sender, feeToSend); rewardToken.lastBalance = ERC20(rewardToken.token).balanceOf(address(this)); unchecked { ++i; } } }
Consider the following scenario.
It will cause some external rewards to be locked in the contract
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/WstethLiquidityVault.sol#L192-L216
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L496-L503
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L736-L766
Manual Review
Use _accumulateExternalRewards and _updateExternalRewardState in claimFees to accrue rewards.
function claimFees() external onlyRole("liquidityvault_admin") { uint256 numInternalRewardTokens = internalRewardTokens.length; uint256 numExternalRewardTokens = externalRewardTokens.length; for (uint256 i; i < numInternalRewardTokens; ) { address rewardToken = internalRewardTokens[i].token; uint256 feeToSend = accumulatedFees[rewardToken]; accumulatedFees[rewardToken] = 0; ERC20(rewardToken).safeTransfer(msg.sender, feeToSend); unchecked { ++i; } } + uint256[] memory accumulatedExternalRewards = _accumulateExternalRewards(); for (uint256 i; i < numExternalRewardTokens; ) { + _updateExternalRewardState(i, accumulatedExternalRewards[i]); ExternalRewardToken storage rewardToken = externalRewardTokens[i]; uint256 feeToSend = accumulatedFees[rewardToken.token]; accumulatedFees[rewardToken.token] = 0; ERC20(rewardToken.token).safeTransfer(msg.sender, feeToSend); rewardToken.lastBalance = ERC20(rewardToken.token).balanceOf(address(this)); unchecked { ++i; } } }
IAm0x52
Escalate for 25 USDC.
This should be medium for two reasons:
sherlock-admin
Escalate for 25 USDC.
This should be medium for two reasons:
- Funds aren't actually lost because they can be rescued
- This is an admin only function so unless admin was malicious and called this repeatedly the amount of locked tokens would be small
You've created a valid escalation for 25 USDC!
To remove the escalation from consideration: Delete your comment.
To change the amount you've staked on this escalation: Edit your comment (do not create a new comment).
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
thereksfour
Escalate for 25 USDC.
Disagree with @IAm0x52 's comments
1.Funds aren't actually lost because they can be rescued
For users, they have lost the rewards they deserve, and even though they can get a refund afterwards, the reputation of the protocol has been compromised.
2.This is an admin only function so unless admin was malicious and called this repeatedly the amount of locked tokens would be small.
Using minimum impact to downgrade the issue here doesn't hold water. I could say that a large number of rewards are left in aura due to a long period of no user activity, and when a malicious user observes the owner calling claimFees, he can preempt the call to getReward to make a large number of rewards locked in the contract
sherlock-admin
Escalate for 25 USDC.
Disagree with @IAm0x52 's comments1.Funds aren't actually lost because they can be rescued
For users, they have lost the rewards they deserve, and even though they can get a refund afterwards, the reputation of the protocol has been compromised.
2.This is an admin only function so unless admin was malicious and called this repeatedly the amount of locked tokens would be small.
Using minimum impact to downgrade the issue here doesn't hold water. I could say that a large number of rewards are left in aura due to a long period of no user activity, and when a malicious user observes the owner calling claimFees, he can preempt the call to getReward to make a large number of rewards locked in the contract
You've created a valid escalation for 25 USDC!
To remove the escalation from consideration: Delete your comment.
To change the amount you've staked on this escalation: Edit your comment (do not create a new comment).
You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.
hrishibhat
Escalation accepted
This is a valid medium
There are multiple reasons why this issue should be medium,
While there is still a dos attack possible, funds are not lost. And can be recovered by admin.
Also, the claimFees is an admin function.
This does not break the core functionality but a DOS of rewards. Hence medium is fair
sherlock-admin
Escalation accepted
This is a valid medium
There are multiple reasons why this issue should be medium,
While there is still a dos attack possible, funds are not lost. And can be recovered by admin.
Also, the claimFees is an admin function.
This issue's escalations have been accepted!
Contestants' payouts and scores will be updated according to the changes made on this issue.
Source: https://github.com/sherlock-audit/2023-02-olympus-judging/issues/44
joestakey, cccz, mahdikarimi, xAlismx, hansfriese, GimelSec, cducrest-brainbot, 0x52, Ruhum, rvierdiiev
SingleSidedLiquidityVault._accumulateInternalRewards will revert with underflow error if rewardToken.lastRewardTime is bigger than current time
Function _accumulateInternalRewards
is used by almost all external function of SingleSidedLiquidityVault.
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L463-L484
function _accumulateInternalRewards() internal view returns (uint256[] memory) { uint256 numInternalRewardTokens = internalRewardTokens.length; uint256[] memory accumulatedInternalRewards = new uint256[](numInternalRewardTokens); for (uint256 i; i < numInternalRewardTokens; ) { InternalRewardToken memory rewardToken = internalRewardTokens[i]; uint256 totalRewards; if (totalLP > 0) { uint256 timeDiff = block.timestamp - rewardToken.lastRewardTime; totalRewards = (timeDiff * rewardToken.rewardsPerSecond); } accumulatedInternalRewards[i] = totalRewards; unchecked { ++i; } } return accumulatedInternalRewards; }
The line is needed to see is this uint256 timeDiff = block.timestamp - rewardToken.lastRewardTime
. In case if rewardToken.lastRewardTime > block.timestamp
than function will revert and ddos functions that use it.
This is how this can happen.
https://github.com/sherlock-audit/2023-02-olympus/blob/main/src/policies/lending/abstracts/SingleSidedLiquidityVault.sol#L674-L688
function addInternalRewardToken( address token_, uint256 rewardsPerSecond_, uint256 startTimestamp_ ) external onlyRole("liquidityvault_admin") { InternalRewardToken memory newInternalRewardToken = InternalRewardToken({ token: token_, decimalsAdjustment: 10**ERC20(token_).decimals(), rewardsPerSecond: rewardsPerSecond_, lastRewardTime: block.timestamp > startTimestamp_ ? block.timestamp : startTimestamp_, accumulatedRewardsPerShare: 0 }); internalRewardTokens.push(newInternalRewardToken); }
In case if startTimestamp_
is in the future, then it will be set and cause that problem.
lastRewardTime: block.timestamp > startTimestamp_ ? block.timestamp : startTimestamp_
.
Now till, startTimestamp_
time, _accumulateInternalRewards
will not work, so vault will be stopped.
And of course, admin can remove that token and everything will be fine. That's why i think this is medium.
SingleSidedLiquidityVault will be blocked
Provided above.
Manual Review
Skip token if it's lastRewardTime
is in future.