Platform: Code4rena
Start Date: 09/09/2021
Pot Size: $60,000 USDC
Total HM: 24
Participants: 12
Period: 7 days
Judge: GalloDaSballo
Total Solo HM: 14
Id: 30
League: ETH
Rank: 1/12
Findings: 14
Award: $8,607.90
🌟 Selected for report: 19
🚀 Solo Findings: 6
🌟 Selected for report: cmichel
cmichel
The Controller.setCap
function sets a cap for a strategy and withdraws any excess amounts (_diff
).
The vault balance is decreased by the entire strategy balance instead of by this _diff
:
// @audit why not sub _diff? _vaultDetails[_vault].balance = _vaultDetails[_vault].balance.sub(_balance);
The _vaultDetails[_vault].balance
variable does not correctly track the actual vault balances anymore, it will usually underestimate the vault balance.
This variable is used in Controller.balanceOf()
, which in turn is used in Vault.balance()
, which in turn is used to determine how many shares to mint / amount to receive when redeeming shares.
If the value is less, users will lose money as they can redeem fewer tokens.
Also, an attacker can deposit
and will receive more shares than they should receive. They can then wait until the balance is correctly updated again and withdraw their shares for a higher amount than they deposited. This leads to the vault losing tokens.
Sub the _diff
instead of the balance
: _vaultDetails[_vault].balance = _vaultDetails[_vault].balance.sub(_diff);
#0 - Haz077
2021-09-30T20:52:21Z
Already fixed in code-423n4/2021-09-yaxis#1
#1 - GalloDaSballo
2021-10-14T16:50:49Z
Finding is valid, has been mitigated by sponsor as of 14 days ago
cmichel
The Vault.balanceOfThis
function values all tokens equally.
They are normalized to 18 decimals and then simply added up:
for (uint8 i; i < _tokens.length; i++) { address _token = _tokens[i]; // adds up different tokens here, treating them as exactly equal value 1.0 A = 1.0 B _balance = _balance.add(_normalizeDecimals(_token, IERC20(_token).balanceOf(address(this)))); }
The balanceOfThis
function does not take into account the actual market prices these tokens trade at.
It would treat 1.0 USDC
as 1.0 WETH
and simply add these values.
Even if only stablecoins are used, they can still differ in value, especially with non-reserve-backed algorithmic stablecoins like DAI
.
If DAI
is valued at 0.99$ but USDC
is valued at 1.00$, then arbitragers will deposit(DAI, amount)
, receive shares
, withdraw the shares for USDC
(withdraw(shares, USDC)
) and make a profit (assuming the _withdrawalProtectionFee
is set to an adequate value).
The contract will run out of the higher-valued USDC
asset immediately, and overall lose total value.
Use a price oracle for each token with a common denominating asset, iterate over the tokens, convert them with the oracle price, and then add them up.
Alternatively, set a high withdrawal fee _withdrawalProtectionFee
but this is bad for the user and could turn off users from depositing anything.
Assume one gets 12% APY but the withdrawal fee is set to 2%, a user will lose money if they withdraw before 2 months.
#0 - Haz077
2021-09-30T19:58:33Z
Duplicate of #2.
#1 - GalloDaSballo
2021-10-14T16:49:32Z
Duplicate of #2
🌟 Selected for report: cmichel
465.1532 YAXIS - $1,814.10
cmichel
The Vault.balance
function uses the balanceOfThis
function which scales ("normalizes") all balances to 18 decimals.
for (uint8 i; i < _tokens.length; i++) { address _token = _tokens[i]; // everything is padded to 18 decimals _balance = _balance.add(_normalizeDecimals(_token, IERC20(_token).balanceOf(address(this)))); }
Note that balance()
's second term IController(manager.controllers(address(this))).balanceOf()
is not normalized.
The code is adding a non-normalized amount (for example 6 decimals only for USDC) to a normalized (18 decimals).
The result is that the balance()
will be under-reported.
This leads to receiving wrong shares when deposit
ing tokens, and a wrong amount when redeeming tokens
.
The second term IController(manager.controllers(address(this))).balanceOf()
must also be normalized before adding it.
IController(manager.controllers(address(this))).balanceOf()
uses _vaultDetails[msg.sender].balance
which directly uses the raw token amounts which are not normalized.
#0 - GalloDaSballo
2021-10-14T16:05:12Z
balance
and balanceOfThis
mixes the usage of decimals by alternatingly using _normalizeDecimals
This can break accounting as well as create opportunities for abuse
A consistent usage of _normalizeDecimals
would mitigate
cmichel
The Vault.balance
function uses the balanceOfThis
function which scales ("normalizes") all balances to 18 decimals.
for (uint8 i; i < _tokens.length; i++) { address _token = _tokens[i]; // everything is padded to 18 decimals _balance = _balance.add(_normalizeDecimals(_token, IERC20(_token).balanceOf(address(this)))); }
Note that balance()
's second term IController(manager.controllers(address(this))).balanceOf()
is not normalized, but it must be.
This leads to many issues through the contracts that use balance
but don't treat these values as normalized values.
For example, in Vault.withdraw
, the computed _amount
value is normalized (in 18 decimals).
But the uint256 _balance = IERC20(_output).balanceOf(address(this));
value is not normalized but compared to the normalized _amount
and even subtracted:
// @audit compares unnormalzied output to normalized output if (_balance < _amount) { IController _controller = IController(manager.controllers(address(this))); // @audit cannot directly subtract unnormalized uint256 _toWithdraw = _amount.sub(_balance); if (_controller.strategies() > 0) { _controller.withdraw(_output, _toWithdraw); } uint256 _after = IERC20(_output).balanceOf(address(this)); uint256 _diff = _after.sub(_balance); if (_diff < _toWithdraw) { _amount = _balance.add(_diff); } }
Imagine in withdraw
, the output
is USDC with 6 decimals, then the normalized _toWithdraw
with 18 decimals (due to using _amount
) will be a huge number and attempt to withdraw an inflated amount.
An attacker can steal tokens this way by withdrawing a tiny amount of shares and receive an inflated USDC or USDT amount (or any _output
token with less than 18 decimals).
Whenever using anything involving vault.balanceOfThis()
or vault.balance()
one needs to be sure that any derived token amount needs to be denormalized again before using them.
#0 - GalloDaSballo
2021-10-14T16:08:28Z
An inconsistent usage of _normalizeDecimals
will cause accounting issues and potentially paths for an exploit
cmichel
The Controller.withdrawAll
decreases the vault balance by _amount
, the want
token amount that has been withdrawn from the strategy and transferred to the vault.
Note that _amount
gets overwritten in the _convert != address(0)
branch and is a _convert
token value instead of a _want
token value:
if (_convert != address(0)) { // @audit _amount is overwritten here with the traded return amount of _convert token! _amount = _converter.convert(_want, _convert, _amount, 1); IERC20(_convert).safeTransfer(_vault, _amount); } else { IERC20(_want).safeTransfer(_vault, _amount); } if (_balance >= _amount) { // @audit they subtract the wrong _amount here if _convert was set _vaultDetails[_vault].balance = _vaultDetails[_vault].balance.sub(_amount); }
The _vaultDetails[_vault].balance
variable does not correctly track the actual vault balances anymore as the traded amount of _convert
token could have a completely different value and decimals
than the want
token.
It could heavily over-or-underestimate the actual withdrawn want
amount.
This variable is used in Controller.balanceOf()
, which in turn is used in Vault.balance()
, which in turn is used to determine how many shares to mint / amount to receive when redeeming shares.
If the value is less, users will lose money as they can redeem fewer tokens.
If the value is more, users can redeem their existing shares for more value, leading to a bankrun until the vault is emptied and other users cannot redeem anymore.
Do not overwrite the _amount
variable in the if
branch.
#0 - GainsGoblin
2021-10-06T19:40:27Z
Duplicate of #8
#1 - GalloDaSballo
2021-10-14T16:51:43Z
Duplicate of #77
Also adding slippage checks in controller would mitigate the specific attack mentioned here
cmichel
There are ERC20 tokens that may make certain customizations to their ERC20 contracts.
One type of these tokens is deflationary tokens that charge a certain fee for every transfer()
or transferFrom()
.
Others are rebasing tokens that increase in value over time like Aave's aTokens (balanceOf
changes over time).
The VaultHelper
's depositVault()
and depositMultipleVault
functions transfer _amount
to this
contract using IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
.
This could have a fee, and less than _amount
ends up in the contract.
The next actual vault deposit using IVault(_vault).deposit(_token, _amount);
will then try to transfer more than the this
contract actually has and will revert the transaction.
One possible mitigation is to measure the asset change right before and after the asset-transferring routines.
This is already done correctly in the Vault.deposit
function.
#0 - Haz077
2021-09-27T18:45:35Z
Same as #62 & #13 in my opinion
#1 - GalloDaSballo
2021-10-12T23:25:22Z
Agree with finding, checking actual balance of contract would mitigate vulnerability
Additionally ensuring the protocol never uses rebasing or tokens with feeOnTransfer
can be enough to mitigate
The vulnerability can brick the protocol
However it can be sidestepped by simply not using feeOnTransfer
tokens
Downgrading to medium
cmichel
The ERC20.transfer()
and ERC20.transferFrom()
functions return a boolean value indicating success. This parameter needs to be checked for success.
Some tokens do not revert if the transfer failed but return false
instead.
The Manager.recoverToken
function does not check the return value of this function.
Tokens that don't actually perform the transfer and return false
are still counted as a correct transfer.
Furthermore, tokens that do not correctly implement the EIP20 standard, like USDT which does not return a success boolean, will revert.
We recommend using OpenZeppelin’s SafeERC20
versions with the safeTransfer
and safeTransferFrom
functions that handle the return value check as well as non-standard-compliant tokens.
#0 - Haz077
2021-09-27T17:47:14Z
Same as #5
#1 - GalloDaSballo
2021-10-13T23:03:06Z
Agree with finding, using a non reverting token can potentially cause issues to the protocol accounting
Sponsor can check each token on a case by case basis, or simply use OpenZeppelin's safeERC20
cmichel
The Vault.withdraw
function attempts to withdraw funds from the controller if there are not enough in the vault already.
In the case the controller could not withdraw enough, i.e., where _diff < _toWithdraw
, the user will receive less output tokens than their fair share would entitle them to (the initial _amount
).
if (_diff < _toWithdraw) { // @audit burns too many shares for a below fair-share amount _amount = _balance.add(_diff); }
The withdrawer receives fewer output tokens than they were entitled to.
In the mentioned case, the _shares
should be recomputed to match the actual withdrawn _amount
tokens:
if (_diff < _toWithdraw) { _amount = _balance.add(_diff); // recompute _shares to burn based on the lower payout // should be something like this, better to cache balance() once at the start and use that cached value _shares = (totalSupply().mul(_amount)).div(_balance); }
Only these shares should then be burned.
#0 - uN2RVw5q
2021-10-02T16:11:28Z
#1 - GalloDaSballo
2021-10-13T23:19:14Z
Agree with the finding
Anytime the strategy incurs a loss during withdrawal, the person that triggered that withdrawal will get less for their shares than what they may expect.
Since amount of shares is computed by checking balance in strategy, and controller enacts this withdrawal, adding a check in the controller to compare expected withdrawal vs actual shares received would be a clean way to mitigate
🌟 Selected for report: cmichel
139.546 YAXIS - $544.23
cmichel
The Controller.inCaseStrategyGetStuck
withdraws from a strategy but does not call updateBalance(_vault, _strategy)
afterwards.
The _vaultDetails[_vault].balances[_strategy]
variable does not correctly track the actual strategy balance anymore.
I'm not sure what exactly this field is used for besides getting the withdraw amounts per strategy in getBestStrategyWithdraw
.
As the strategy contains a lower amount than stored in the field, Controller.withdraw
will attempt to withdraw too much.
Call updateBalance(_vault, _strategy)
in inCaseStrategyGetStuck
.
#0 - GalloDaSballo
2021-10-14T14:14:38Z
Agree with finding, I also believe inCaseStrategyGetStuck
and inCaseTokenGetStuck
are vectors for admin rugging, may want to add checks to ensure only non strategy token can be withdrawn from the vaults and strats
🌟 Selected for report: cmichel
cmichel
One vault can have many tokens, but each token should only be assigned to a single vault.
The Manager
contract keeps a mapping of tokens to vaults in the vaults[_token] => _vault
map, and a mapping of vault to tokens in tokens[vault] => token[]
.
The addToken
function can assign any token to a single vault and allows overwriting an existing vaults[_token]
map entry with a different vault.
This indirectly disassociates the previous vault for the token.
Note that the previous vault's tokens[_previousVault]
map still contains the token
.
The token disappears from the system for the previous vault but the actual tokens are still in there, getting stuck.
Only the new vault is considered for the token anymore, which leads to many issues, see Controller.getBestStrategyWithdraw
and the onlyVault
modifier that doesn't work correctly anymore.
It should check if the token
is already used in a map, and either revert or correctly remove the token from the vault - from the tokens
array.
It should do the same cleanup procedure as in removeToken
:
if (found) { // remove the token from the vault tokens[_vault][index] = tokens[_vault][k-1]; tokens[_vault].pop(); delete vaults[_token]; emit TokenRemoved(_vault, _token); }
addToken
should also check that the token is not already in the tokens[_vault]
array.
#0 - GalloDaSballo
2021-10-14T15:19:11Z
Mapping mismatch can cause undefined behaviour
Recommend having one source of truth to keep things simple
🌟 Selected for report: cmichel
cmichel
The YAxisVotePower.balanceOf
contract uses the Uniswap pool reserves to compute a _lpStakingYax
reward:
(uint256 _yaxReserves,,) = yaxisEthUniswapV2Pair.getReserves(); int256 _lpStakingYax = _yaxReserves .mul(_stakeAmount) .div(_supply) .add(rewardsYaxisEth.earned(_voter));
The pool can be temporarily manipulated to increase the _yaxReserves
amount.
If this voting power is used for governance proposals, an attacker can increase their voting power and pass a proposal.
One could build a TWAP-style contract that tracks a time-weighted-average reserve amount (instead of the price in traditional TWAPs). This can then not be manipulated by flashloans.
#0 - uN2RVw5q
2021-10-01T15:55:03Z
I disagree with the "sponsor disputed" tag.
I think this is a valid issue and makes balanceOf(_voter)
susceptible to flashloan attacks. However, as long as balanceOf(_voter)
is always called by a trusted EOA during governance vote counts, this should not be a problem. I assume this is the case for governance proposals. If that is not the case, I would recommend changing the code. Otherwise, changing the risk to "documentation" would be reasonable.
#1 - GalloDaSballo
2021-10-14T15:22:27Z
Agree with original warden finding, as well as severity
The ability to trigger the count at any time does prevent a flashloan attack (as flashloans are atomic) It would allow the privilege of the flashloan attack to the trusted EOA (admin privilege)
Additionally the voting power can still be frontrun, while you cannot manipulate that voting power via a flashloan, you can just buy and sell your position on the same block as when the count is being taken
Due to this I will up the severity back to medium as this is a legitimate vector to extract value
🌟 Selected for report: cmichel
139.546 YAXIS - $544.23
cmichel
The Harvester.getEstimates
contract tries to estimate a YAXIS
amount but uses the wrong path and/or amount.
It currently uses a WETH
input amount to compute a YAXIS -> WETH
trade.
address[] memory _path; _path[0] = IStrategy(_strategy).want(); _path[1] = IStrategy(_strategy).weth(); // ... _path[0] = manager.yaxis(); // path is YAXIS -> WETH now // fee is a WETH precision value uint256 _fee = _estimatedWETH.mul(manager.treasuryFee()).div(ONE_HUNDRED_PERCENT); // this will return wrong trade amounts _amounts = _router.getAmountsOut(_fee, _path); _estimatedYAXIS = _amounts[1];
The estimations from getEstimates
are wrong.
They seem to be used to provide min. amount slippage values (_estimatedWETH, _estimatedYAXIS)
for the harvester when calling Controller._estimatedYAXIS
.
These are then used in BaseStrategy._payHarvestFees
and can revert the harvest transactions if the wrongly computed _estimatedYAXIS
value is above the actual trade value.
Or they can allow a large slippage if the _estimatedYAXIS
value is below the actual trade value, which can then be used for a sandwich attack.
Fix the estimations computations.
As the estimations are used in BaseStrategy._payHarvestFees
, the expected behavior seems to be trading WETH
to YAXIS
.
The path should therefore be changed to path[0] = WETH; path[1] = YAXIS
in Harvester.getEstimates
.
#0 - GalloDaSballo
2021-10-14T15:35:17Z
Price estimates on Uniswap are dependent on which side of the swap you're making
Sponsor has mitigated in later PR
🌟 Selected for report: cmichel
cmichel
Many contracts iterate over the entire strategies
or tokens
array of a vault
For example:
Harvester.removeStrategy
Harvester.harvestNextStrategy
Manager.removeToken
The transactions can fail if the arrays get too big and the transaction would consume more gas than the block limit. This will then result in a denial of service for the desired functionality and break core functionality.
Keep the number of strategies
and tokens
per pool small.
Alternatively use an enumerable set for better efficiency. It does not require iterating over the entire array when trying to find an element in removeStrategy/Token
.
#0 - GalloDaSballo
2021-10-13T23:58:38Z
Agree with finding, this shows a different exploit that can be mitigated by using OpenZeppelin's EnumerableSet
🌟 Selected for report: cmichel
46.5153 YAXIS - $181.41
cmichel
The Withdraw
event in LegacyController.withdraw
emits the _amount
variable which is the initial, desired amount to withdraw. It should emit the actual withdrawn amount instead, which is transferred in the last token.balanceOf(address(this))
call.
The actual withdrawn amount, which can be lower than _amount
, is part of the event.
This is usually not what you want (and it can already be decoded from the function argument).
Use it or remove it.
#0 - GalloDaSballo
2021-10-14T00:00:06Z
Nice catch, sponsor mitigated
🌟 Selected for report: cmichel
46.5153 YAXIS - $181.41
cmichel
Several functions trade without using any slippage protection, their min. return amount is set to 1
:
Controller.setCap: _balance = _converter.convert(_want, _convert, _balance, 1);
Controller.withdrawAll: _amount = _converter.convert(_want, _convert, _amount, 1);
NativeStrategyCurve3Crv._swapTokens: _swapTokens(weth, _stableCoin, _remainingWeth, 1);
Combined with the fact that anyone can call Harvester.harvestNextStrategy
, it's easy to sandwich-attack these.
A common attack in DeFi is the sandwich attack. Upon observing a trade of asset X for asset Y, an attacker frontruns the victim trade by also buying asset Y, lets the victim execute the trade, and then backruns (executes after) the victim by trading back the amount gained in the first trade. Intuitively, one uses the knowledge that someone’s going to buy an asset, and that this trade will increase its price, to make a profit. The attacker’s plan is to buy this asset cheap, let the victim buy at an increased price, and then sell the received amount again at a higher price afterwards.
The protocol makes a bad trade and loses tokens. The profit is not maximized.
Accept a function parameter that can be chosen by the transaction sender, then check that the actually received amount is above this parameter instead of using the hardcoded 1
.
harvestNextStrategy
may not be called by anyone as they could set the slippage parameter very low.
Let the strategist call harvestNextStrategy
.
Check if it's feasible to send these transactions directly to a miner (flashbots) such that they are not visible in the public mempool.
#0 - BobbyYaxis
2021-09-18T21:19:07Z
harvestNextStrategy cannot be called by non-whitelisted addresses
#1 - GalloDaSballo
2021-10-14T14:13:02Z
Warden findings are correct, but they are not properly articulated. Other findings point to risk of front-running, sandwiching and Single Sided Exposure risk
Downgrading to Low Risk
For Sponsor: The fact you can call the function doesn't prevent the trade from being front-run as once you sign the transaction it is publicly available in the mempool, frontrunner can then write sandwiching txs with the goal of maximing MEV out of your trade
cmichel
The Manager.removeToken
function iterates over all tokens to check for existence of an element in the tokens[_vault]
array.
A more efficient solution would be to use OpenZeppelin's EnumerableSet.
It allows iterating (enumerating) over all entries, as well as constant-time existence checks using contains
, as well as a constant time remove
function.
The trade-off is that modifying an element requires two sstores
due to the additional index it needs to keep track of.
#0 - GalloDaSballo
2021-10-12T22:24:00Z
Agree, EnumerableSet is cleaner and typically saves gas as well
#1 - GalloDaSballo
2021-10-12T22:25:07Z
Disagree that it's a duplicate as the finding is specific to a particular functionality different from #117
cmichel
The Harvester.removeStrategy
function iterates over all tokens to check for existence of an element in the strategies[_vault].addresses[_vault]
array.
A more efficient solution would be to use OpenZeppelin's EnumerableSet (or EnumerableMap
).
It allows iterating (enumerating) over all entries, as well as constant-time existence checks using contains
, as well as a constant time remove
function.
The trade-off is that modifying an element requires two sstores
due to the additional index it needs to keep track of.
#0 - GalloDaSballo
2021-10-12T22:25:24Z
Agree, EnumerableSet is cleaner and typically saves gas as well
Disagree that it's a duplicate as the finding is specific to a particular functionality different from #116
cmichel
The Vault.deposit
function performs an unnecessary add
here as _shares
is always zero at this point:
if (_amount > 0) { _amount = _normalizeDecimals(_token, _amount); if (totalSupply() > 0) { _amount = (_amount.mul(totalSupply())).div(_balance); } // @audit gas: just set _shares = _amount, no .add needed _shares = _shares.add(_amount); }
Set _shares = _amount
directly instead of adding amount
to the always zero-valued _shares
.
#0 - Haz077
2021-09-27T17:53:49Z
Same as #6
#1 - GalloDaSballo
2021-10-12T22:26:37Z
Agree with finding, sponsor mitigated, see progress in #116
2.7432 YAXIS - $10.70
cmichel
The Vault.depositMultiple
function has a notHalted
multiplier that the subcalls to deposit
already check.
Furthermore, all repeated deposit
calls check the notHalted
modifier each time.
Consider refactoring the code such that the notHalted
modifier is only evaluated once instead of _tokens.length + 1
times.
#0 - Haz077
2021-09-27T17:56:14Z
Same as #70
#1 - GalloDaSballo
2021-10-12T22:41:40Z
Duplicate of #70
🌟 Selected for report: cmichel
15.052 YAXIS - $58.70
cmichel
The two loops in StablesConverter.convert
can be avoided by using the indices
mapping.
Instead of the for
loop, simply get the index i
by doing:
if (_output == address(token3CRV)) { uint256 i = indices[_input]; amounts[i] = _inputAmount; } else if (_input == address(token3CRV)) { uint256 i = indices[_output]; remove_liquidity_one_coin(_, i); }
This replaces all the tokens[i]
sloads with a single one.
#0 - GalloDaSballo
2021-10-12T22:44:30Z
Using indices instead of looping will save gas, Sponsor mitigated, LG
🌟 Selected for report: cmichel
15.052 YAXIS - $58.70
cmichel
The two loops in StablesConverter.expected
can be avoided by using the indices
mapping.
Instead of the for
loop, simply get the index i
by doing:
if (_output == address(token3CRV)) { uint256 i = indices[_input]; amounts[i] = _inputAmount; } else if (_input == address(token3CRV)) { uint256 i = indices[_output]; return stableSwap3Pool.calc_withdraw_one_coin(_inputAmount, i); }
This replaces all the tokens[i]
sloads with a single one.
#0 - GalloDaSballo
2021-10-12T22:45:32Z
Similarly to #123 , sponsor mitigated, LG
🌟 Selected for report: cmichel
15.052 YAXIS - $58.70
cmichel
When doing swaps with a Uniswap router from within a contract, there's no need to compute any offset from the current block for the deadline
parameter.
The router just checks if deadline >= block.timestamp
.
See BaseStrategy._swapTokens
which does an unnecessary block.timestamp
read and another unnecessary addition of 1800
.
The most efficient way to provide deadlines for a router swap is to use a hardcoded value that is far in the future, for example, 1e10
.
#0 - Haz077
2021-09-27T18:57:28Z
Added in code-423n4/2021-09-yaxis#18
#1 - GalloDaSballo
2021-10-12T22:47:20Z
Sponsor does acknowledge and has mitigated
Not sure why they are using 1e10 when now
would work (may be the case of avoiding a listing message)