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: 1/154
Findings: 5
Award: $13,062.09
🌟 Selected for report: 2
🚀 Solo Findings: 2
🌟 Selected for report: hyh
Also found by: Koolex, Parad0x, chaduke, hansfriese, jasonxiale, koxuan
1140.5041 USDC - $1,140.50
ReaperVaultV2._withdraw()
burns 100% of shares even if the vault balance is less than the required underlying amount.
As a result, users would lose some shares during withdrawal.
Users can receive underlying tokens by burning their shares using _withdraw()
.
If the vault doesn't have enough underlying balance, it withdraws from strategies inside withdrawalQueue
.
File: ReaperVaultV2.sol 359: function _withdraw( 360: uint256 _shares, 361: address _receiver, 362: address _owner 363: ) internal nonReentrant returns (uint256 value) { 364: require(_shares != 0, "Invalid amount"); 365: value = (_freeFunds() * _shares) / totalSupply(); 366: _burn(_owner, _shares); 367: 368: if (value > token.balanceOf(address(this))) { 398: .... 399: vaultBalance = token.balanceOf(address(this)); 400: if (value > vaultBalance) { 401: value = vaultBalance; //@audit should reduce shares accordingly 402: } 403: 404: require( 405: totalLoss <= ((value + totalLoss) * withdrawMaxLoss) / PERCENT_DIVISOR, 406: "Withdraw loss exceeds slippage" 407: ); 408: } 409: 410: token.safeTransfer(_receiver, value); 411: emit Withdraw(msg.sender, _receiver, _owner, value, _shares); 412: }
After withdrawing from the strategies of withdrawalQueue
, it applies the max cap at L401.
But as we can see from setWithdrawalQueue(), withdrawalQueue
wouldn't contain all of the active strategies and the above condition at L400 will be true.
In this case, users will get fewer underlying amounts after burning the whole shares that they requested.
As a reference, it recalculates the shares for the above case in Yearn vault.
if value > vault_balance: value = vault_balance # NOTE: Burn # of shares that corresponds to what Vault has on-hand, # including the losses that were incurred above during withdrawals shares = self._sharesForAmount(value + totalLoss)
Manual Review
We should recalculate the shares and burn them rather than burn all shares.
#0 - c4-judge
2023-03-08T16:30:34Z
trust1995 marked the issue as satisfactory
#1 - c4-judge
2023-03-08T16:30:39Z
trust1995 marked the issue as primary issue
#2 - c4-sponsor
2023-03-13T18:15:02Z
tess3rac7 marked the issue as sponsor confirmed
#3 - c4-judge
2023-03-20T15:55:28Z
trust1995 marked issue #288 as primary and marked this issue as a duplicate of 288
🌟 Selected for report: hansfriese
5858.7427 USDC - $5,858.74
https://github.com/code-423n4/2023-02-ethos/blob/73687f32b934c9d697b97745356cdf8a1f264955/Ethos-Vault/contracts/abstract/ReaperBaseStrategyv4.sol#L109 https://github.com/code-423n4/2023-02-ethos/blob/73687f32b934c9d697b97745356cdf8a1f264955/Ethos-Vault/contracts/ReaperStrategyGranarySupplyOnly.sol#L200
ReaperBaseStrategyv4.harvest()
might revert in an emergency if there is no position on the lending pool.
As a result, the funds might be locked inside the strategy.
The main problem is that Aave lending pool doesn't allow 0 withdrawals.
function validateWithdraw( address reserveAddress, uint256 amount, uint256 userBalance, mapping(address => DataTypes.ReserveData) storage reservesData, DataTypes.UserConfigurationMap storage userConfig, mapping(uint256 => address) storage reserves, uint256 reservesCount, address oracle ) external view { require(amount != 0, Errors.VL_INVALID_AMOUNT);
So the below scenario would be possible.
_adjustPosition()
remains the debt during reinvesting and also, there is an authorizedWithdrawUnderlying()
for STRATEGIST
to withdraw from the lending pool.harvest()
tries to liquidate all positions(=0 actually) and it will revert because of 0 withdrawal from Aave.withdraw()
will revert at L98 as the strategy is in the debt.As a result, the funds might be locked inside the strategy unless the emergency
mode is canceled.
Manual Review
We should check 0 withdrawal in _withdrawUnderlying()
.
function _withdrawUnderlying(uint256 _withdrawAmount) internal { uint256 withdrawable = balanceOfPool(); _withdrawAmount = MathUpgradeable.min(_withdrawAmount, withdrawable); if(_withdrawAmount != 0) { ILendingPool(ADDRESSES_PROVIDER.getLendingPool()).withdraw(address(want), _withdrawAmount, address(this)); } }
#0 - trust1995
2023-03-08T16:21:01Z
Very interesting edge case.
#1 - c4-judge
2023-03-08T16:21:07Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-03-08T16:23:43Z
trust1995 marked the issue as primary issue
#3 - tess3rac7
2023-03-14T16:29:20Z
Valid edge case in as far as harvests would fail. However, funds won't get locked in the strategy. They can still be withdrawn through an appropriate withdraw() TX. Recommend downgrading to low since this is purely about state handling without putting any assets at risk. See screenshot below for simulation:
#4 - c4-sponsor
2023-03-14T16:30:08Z
tess3rac7 marked the issue as disagree with severity
#5 - trust1995
2023-03-20T11:15:18Z
Med severity is also appropriate when core functionality is impaired, even if there is no lasting damage.
#6 - c4-judge
2023-03-20T15:52:51Z
trust1995 marked the issue as selected for report
#7 - c4-sponsor
2023-03-20T18:38:48Z
tess3rac7 marked the issue as sponsor confirmed
🌟 Selected for report: hansfriese
5858.7427 USDC - $5,858.74
The locked profit degradation for the past will be changed with the new lockedProfitDegradation
.
As a result, malicious users can steal others' rewards by frontrunning.
setLockedProfitDegradation()
is used to change lockedProfitDegradation
by admin.
function setLockedProfitDegradation(uint256 degradation) external { _atLeastRole(ADMIN); require(degradation <= DEGRADATION_COEFFICIENT, "Degradation cannot be more than 100%"); lockedProfitDegradation = degradation; emit LockedProfitDegradationUpdated(degradation); }
And lockedProfitDegradation
is used to calculate the locked profit.
function _calculateLockedProfit() internal view returns (uint256) { uint256 lockedFundsRatio = (block.timestamp - lastReport) * lockedProfitDegradation; if (lockedFundsRatio < DEGRADATION_COEFFICIENT) { return lockedProfit - ((lockedFundsRatio * lockedProfit) / DEGRADATION_COEFFICIENT); } return 0; }
But it doesn't update lockedProfit
and lastReport
before changing lockedProfitDegradation
so the below scenario would be possible.
lockedProfit = 200, lastReport = block.timestamp
after calling report()
, lockedProfitDegradation
are 6 hours in blocks
.lockedProfit
are released and added to the free funds. We can assume report()
wasn't called for 3 hours.lockedProfitDegradation
is changed to 4 hours in blocks
and it means 200 * 3 / 4 = 150
tokens are released. As a result, free funds are increased by 50 inside the same block.deposit()
with huge amounts before lockedProfitDegradation
is changed and charge most of the new rewards(=50).Similarly, already unlocked funds will be treated as locked again if lockedProfitDegradation
is decreased.
Even if there is no front run as all depositors are pools, it's not fair to change locked/unlocked amounts that are confirmed already.
Manual Review
We should modify setLockedProfitDegradation()
like below.
function setLockedProfitDegradation(uint256 degradation) external { _atLeastRole(ADMIN); require(degradation <= DEGRADATION_COEFFICIENT, "Degradation cannot be more than 100%"); // update lockedProfit and lastReport lockedProfit = _calculateLockedProfit(); lastReport = block.timestamp; lockedProfitDegradation = degradation; emit LockedProfitDegradationUpdated(degradation); }
#0 - c4-judge
2023-03-08T16:23:08Z
trust1995 changed the severity to 3 (High Risk)
#1 - c4-judge
2023-03-08T16:23:13Z
trust1995 marked the issue as satisfactory
#2 - c4-judge
2023-03-08T16:23:25Z
trust1995 marked the issue as primary issue
#3 - tess3rac7
2023-03-13T17:47:44Z
I don't think this qualifies as a "high". It relies on a large harvest, followed by no harvests for a considerable amount of time, followed by the ADMIN (multisig) calling setLockedProfitDegradation()
to set the degradation to a smaller value and being front-run by a malicious user calling deposit()
.
DEPOSITOR
role, so in this case they are trustedsetLockedProfitDegradation()
, and ADMIN's TX gets front-run by said malicious depositor, it would, at best, result in some extra yield being squeezed by the malicious depositor--ONLY IF they have enough capital to overshadow the vault's TVL. There would be no loss of principal assets whatsoever for any depositor.There is slim chance for favorable conditions for this type of attack to succeed, and the capital requirements for the attacker would be quite high. And even after doing all this, they would at best earn a few more tokens out of the locked yield without impact any other user's deposits. It's not even a repeatable attack because ADMIN won't keep calling setLockedProfitDegradation()
over and over. Very low risk in my opinion.
#4 - c4-sponsor
2023-03-13T17:47:53Z
tess3rac7 marked the issue as disagree with severity
#5 - c4-judge
2023-03-20T13:05:55Z
trust1995 changed the severity to 2 (Med Risk)
#6 - c4-judge
2023-03-20T15:53:19Z
trust1995 marked the issue as selected for report
🌟 Selected for report: GalloDaSballo
Also found by: 0xBeirao, 0xRobocop, AkshaySrivastav, KingNFT, MiloTruck, PaludoX0, bin2chen, hansfriese, imare, kaden
142.8544 USDC - $142.85
https://github.com/code-423n4/2023-02-ethos/blob/73687f32b934c9d697b97745356cdf8a1f264955/Ethos-Core/contracts/ActivePool.sol#L251 https://github.com/code-423n4/2023-02-ethos/blob/73687f32b934c9d697b97745356cdf8a1f264955/Ethos-Core/contracts/ActivePool.sol#L282 https://github.com/code-423n4/2023-02-ethos/blob/73687f32b934c9d697b97745356cdf8a1f264955/Ethos-Core/contracts/ActivePool.sol#L288
The _rebalance() reverts if a strategy gets loss. Because _rebalance() is called on all important workflows, this leads to insolvency of the protocol.
The protocol uses ReaperVaultERC4626 to manage the collateral assets and farm profit. The vaults are connected to whitelisted strategies.
But it is not guaranteed that the strategies earn profit all the time.
On the other hand, in several places of ActivePool._rebalance(), the protocol assumes that it can get more than deposits all the time.
At L251, where the protocol calculates the profit by subtracting the stored yieldingAmount
from the sharesToAssets
, this will revert if the strategy got loss.
ActivePool.sol 242: // how much has been allocated as per our internal records? 243: vars.currentAllocated = yieldingAmount[_collateral];//@audit-info current yield deposit 244: 245: // what is the present value of our shares? 246: vars.yieldGenerator = IERC4626(yieldGenerator[_collateral]); 247: vars.ownedShares = vars.yieldGenerator.balanceOf(address(this)); 248: vars.sharesToAssets = vars.yieldGenerator.convertToAssets(vars.ownedShares); 249: 250: // if we have profit that's more than the threshold, record it for withdrawal and redistribution 251: vars.profit = vars.sharesToAssets.sub(vars.currentAllocated);//@audit-issue profit from the farming, this can revert! 252: if (vars.profit < yieldClaimThreshold[_collateral]) { 253: vars.profit = 0; 254: }
At L282 where the protocol withdraws specifying the collateral amount to receive but it will revert if the strategy lost.
ActivePool.sol 276: // + means deposit, - means withdraw 277: vars.netAssetMovement = int256(vars.toDeposit) - int256(vars.toWithdraw) - int256(vars.profit); 278: if (vars.netAssetMovement > 0) { 279: IERC20(_collateral).safeIncreaseAllowance(yieldGenerator[_collateral], uint256(vars.netAssetMovement)); 280: IERC4626(yieldGenerator[_collateral]).deposit(uint256(vars.netAssetMovement), address(this)); 281: } else if (vars.netAssetMovement < 0) { 282: IERC4626(yieldGenerator[_collateral]).withdraw(uint256(-vars.netAssetMovement), address(this), address(this));//@audit-info coll received, this can revert 283: }
Because _rebalance() is called on all important workflows, this leads to insolvency of the protocol.
Manual Review
Do not assume sharesToAssets>yieldingAmount
at all places mentioned and handle appropriately.
#0 - c4-judge
2023-03-08T15:42:44Z
trust1995 marked the issue as primary issue
#1 - c4-judge
2023-03-08T15:42:48Z
trust1995 marked the issue as satisfactory
#2 - tess3rac7
2023-03-13T18:29:35Z
ActivePool is not designed to handle losses. The vault must be lossless and the strategies we employ will be super risk-off the ensure this. Still, if there are losses, we want the protocol to temporarily break so that we can send tokens directly to the vault to make up for any losses. If we allow for execution when there are losses, the protocol will become corrupt because we have no way of accounting for losses or distributing them to trove owners (since ActivePool maintains aggregate values).
Some wardens asked me about this while the contest was ongoing and I did confirm the same. I understand that ReaperVaultV2 has the ability to pass on losses to its consumers, however ReaperVaultV2 is a generic piece of software (not tied to Ethos protocol), and will be employed in a purely lossless manner for Ethos. Current value of withdrawMaxLoss
within ReaperVaultV2 is 1 basis point (0.01%) and we might even change that to 0 to make it more explicit -- but there will be no difference in behavior either way.
#3 - c4-sponsor
2023-03-13T18:29:45Z
tess3rac7 marked the issue as sponsor disputed
#4 - tess3rac7
2023-03-14T15:05:10Z
Also stated explicitly in docs: https://docs.reaper.farm/ethos-reserve-bounty-hunter-documentation/contracts-in-scope/activepool
Relies on ReaperVaultV2 to be monotonically increasing in balance (no loss), and periodically claims profits for redistribution to treasury/stability pool/staking pool per configurable split.
#5 - c4-judge
2023-03-20T12:48:04Z
trust1995 marked the issue as unsatisfactory: Out of scope
#6 - c4-judge
2023-03-22T10:23:46Z
trust1995 marked the issue as satisfactory
#7 - c4-judge
2023-03-22T10:24:01Z
trust1995 changed the severity to 2 (Med Risk)
#8 - c4-judge
2023-03-22T10:24:14Z
trust1995 marked the issue as duplicate of #632
🌟 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
The version of OpenZeppelin in the core package falls within this advisory, so advised to upgrade to avoid accidentally adding a vulnerability if external calls are made.
report()
function and cause problems.
(It is understood strategy is added by admin and this is very unlikely)When steps
are not initialized it seems admin has to setHarvestSteps
first, but this may not happen so rewards aren't swapped.
_disableInitializers()
_disableInitializers()
in the initializer function.require(_feeBPS <= PERCENT_DIVISOR / 5, "Fee cannot be higher than 20 BPS");
I believe the code is correct and the event string is misleading here, should be changed into 2000 BPS or 20%.inCaseTokensGetStuck
called with legacy addressReaperVaultV2::inCaseTokensGetStuck
can be called by an admin to withdraw tokens which are mistakenly sent to the vault.
A double entrypoint token messes this up to cause complete withdrawal, breaking the vault.
Although it is understood collateral tokens are added by admin; it certainly wouldn't be ideal as collaterals are immutable and USDT being upgradeable/high quality/large market cap collateral makes it a good candidate for this vulnerability.
previewDeposit
and deposit
of ReapervaultV2
https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Vault/contracts/ReaperVaultERC4626.sol#L52
https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Vault/contracts/ReaperVaultV2.sol#L334
If _freeFunds()==0
, the ReapervaultV2::previewDeposit
returns the same amount to the input assets
value.
But in actual deposit, it reverts when freeFunds==0
at ReaperVaultV2.sol#L334
ReaperVaultV2::setLockedProfitDegradation
https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Vault/contracts/ReaperVaultV2.sol#L617
lockedProfitDegradation
should be greater than zero or else funds are not released.
It is understood that it is reversible by calling the same function with a correct value, but recommend to add this check.
https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Core/contracts/ActivePool.sol#L194; https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Core/contracts/ActivePool.sol#L201; https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Core/contracts/SortedTroves.sol#L198
In many places, the protocol is using uint
and uint256
.
It is recommended to use consistent type names.
_troveArray
into _troveOwnerArray
.The modifier checkCollateral
can be renamed to be more explicit with what it is "checking", for example:
isValidCollateral isAllowedCollateral requireValidCollateral
withdrawFromSp
doesn't _requireNonZeroAmount()
unlike provideToSP
Although this does not seems to cause problems, it is recommended to keep consistency for these functions.
ReaperStrategygranarySupplyOnly.initialize()
All instances should be removed.
https://github.com/code-423n4/2023-02-ethos/blob/main/Ethos-Core/contracts/ActivePool.sol#L327-L335
It is good to include the actual redemptionHelper
address value.
#0 - c4-judge
2023-03-08T16:00:56Z
trust1995 marked the issue as grade-b
#1 - c4-sponsor
2023-03-17T22:49:31Z
0xBebis marked the issue as sponsor confirmed