Platform: Code4rena
Start Date: 09/12/2021
Pot Size: $50,000 USDC
Total HM: 19
Participants: 21
Period: 7 days
Judge: 0xean
Total Solo HM: 14
Id: 61
League: ETH
Rank: 1/21
Findings: 6
Award: $7,086.37
π Selected for report: 9
π Solo Findings: 2
π Selected for report: cmichel
2816.7538 USDC - $2,816.75
cmichel
The yearn strategy YearnYield
converts shares to tokens by doing pricePerFullShare * shares / 1e18
:
function getTokensForShares(uint256 shares, address asset) public view override returns (uint256 amount) { if (shares == 0) return 0; // @audit should divided by vaultDecimals amount = IyVault(liquidityToken[asset]).getPricePerFullShare().mul(shares).div(1e18); }
But Yearn's getPricePerFullShare
seems to be in vault.decimals()
precision, i.e., it should convert it as pricePerFullShare * shares / (10 ** vault.decimals())
.
The vault decimals are the same as the underlying token decimals
The token and shares conversions do not work correctly for underlying tokens that do not have 18 decimals. Too much or too little might be paid out leading to a loss for either the protocol or user.
Divide by 10**vault.decimals()
instead of 1e18
in getTokensForShares
.
Apply a similar fix in getSharesForTokens
.
760.5235 USDC - $760.52
cmichel
When depositing into Aave through the AaveYield.lockTokens
contract strategy, one receives the sharesReceived
amount corresponding to the diff of aToken
balance, which is just always the deposited amount as aave is a rebasing token and 1.0 aToken = 1.0 underlying
at each deposit / withdrawal.
Note that this sharesReceived
(the underlying deposit amount) is cached in a balanceInShares
map in SavingsAccount.deposit
which makes this share static and not dynamically rebasing anymore:
function deposit( uint256 _amount, address _token, address _strategy, address _to ) external payable override nonReentrant returns (uint256) { require(_to != address(0), 'SavingsAccount::deposit receiver address should not be zero address'); uint256 _sharesReceived = _deposit(_amount, _token, _strategy); balanceInShares[_to][_token][_strategy] = balanceInShares[_to][_token][_strategy].add(_sharesReceived); emit Deposited(_to, _sharesReceived, _token, _strategy); return _sharesReceived; } function getTokensForShares(uint256 shares, address asset) public view override returns (uint256 amount) { if (shares == 0) return 0; address aToken = liquidityToken(asset); (, , , , , , , uint256 liquidityIndex, , ) = IProtocolDataProvider(protocolDataProvider).getReserveData(asset); // @audit-info tries to do (user shares / total shares) * underlying amount where underlying amount = scaledBalance * liquidityIndex amount = IScaledBalanceToken(aToken).scaledBalanceOf(address(this)).mul(liquidityIndex).mul(shares).div( IERC20(aToken).balanceOf(address(this)) ); }
However, the getTokensForShares
function uses a rebasing total share supply of IERC20(aToken).balanceOf(this)
.
balanceInShares[user][dai][aave]
.withdraw
s 1000 aDAI shares for user which calls AaveYield.unlockTokens
. The user receives only 1000 DAI. The interest owed to the user is not paid out.getTokensForShares
also returns the wrong amount as 1200 * 1000 / 1200 = 1000
Interest is not paid out to users.
Pool collateral is measured without the interest accrued as it uses getTokensForShares
which will lead to early liquidations and further loss.
If the user shares are not rebasing, you cannot have the "total shares supply" (the shares in the contract) be rebasing as in getTokensForShares
. Also withdrawing the share amount directly from Aave as in _withdrawERC
does not withdraw the yield.
A fix could be to create a non-rebasing wrapper LP token that is paid out to the user proportional to the current strategy TVL at time of user deposit.
#0 - ritik99
2021-12-27T09:48:07Z
We've been aware of this issue for some time.. ended up including the AaveYield file in the scope by mistake! We do not plan to include the Aave strategy in our launch (we maintain a strategy registry that allows us to add/drop yield strategies), and as noted in #128, we will be utilizing wrapper contracts that mimics behaviour of non-rebasing LP tokens
#1 - 0xean
2022-01-21T00:37:35Z
going to side with the warden since they believed the contract to be in scope and it's a valid concern.
1267.5392 USDC - $1,267.54
cmichel
The CreditLine.borrow
function increases the principal by the _amount
parameter, but the borrower only receives _tokenDiffBalance
.
// @audit increases by _amount instead of _tokenDiffBalance creditLineVariables[_id].principal = creditLineVariables[_id].principal.add(_amount); creditLineVariables[_id].lastPrincipalUpdateTime = block.timestamp; uint256 _tokenDiffBalance; if (_borrowAsset != address(0)) { uint256 _balanceBefore = IERC20(_borrowAsset).balanceOf(address(this)); _withdrawBorrowAmount(_borrowAsset, _amount, _lender); uint256 _balanceAfter = IERC20(_borrowAsset).balanceOf(address(this)); _tokenDiffBalance = _balanceAfter.sub(_balanceBefore); }
It could be that one of the strategies does currently not return the entire desired withdraw amount but less, for example, if a lending protocol's pool is currently fully lent out. The borrower is credited more debt than they received.
Increase principal by the _tokenDiffBalance
(before fees).
#0 - 0xean
2022-01-21T20:38:29Z
dupe of #80
π Selected for report: cmichel
845.0261 USDC - $845.03
cmichel
The Extension
contract correctly reduces votes from the from
address of a transfer and adds it to the to
address of the transfer (in case both of them voted on it before), but it does not rerun the voting logic in voteOnExtension
that actually grants the extension.
This leads to issues where an extension should be granted but is not:
to
address has 100 tokens and votes for the extensionfrom
address has 100 tokens but does not vote for the extension and transfers the 100 tokens to to
to
now has 200 tokens, removeVotes
is run, the totalExtensionSupport
is increased by 100 to 200. In theory, the threshold is reached and the vote should pass if to
could call voteOnExtension
again.voteOnExtension
with the new balance will fail as they already voted on it (lastVotedExtension == _extensionVoteEndTime
). The extension is not granted.Extensions that should be granted after a token transfer are not granted.
Rerun the threshold logic in removeVotes
as it has the potential to increase the total support if to
voted for the extension but from
did not.
cmichel
The initialize
function that initializes important contract state can be called by anyone.
See:
PriceOracle.initialize
PoolFactory.initialize
Repayments.initialize
CreditLine.initialize
The attacker can initialize the contract before the legitimate deployer, hoping that the victim continues to use the same contract. In the best case for the victim, they notice it and have to redeploy their contract costing gas.
Use the constructor to initialize non-proxied contracts.
For initializing proxy contracts deploy contracts using a factory contract that immediately calls initialize
after deployment or make sure to call it immediately after deployment and verify the transaction succeeded.
#0 - ritik99
2021-12-27T13:33:58Z
Duplicate of #106
π Selected for report: cmichel
281.6754 USDC - $281.68
cmichel
The Extension
contract uses the current balance (IPool.getBalanceDetails(msg.sender)
) as the voting power.
If this passes the threshold the extension is granted, doesn't matter that removeVotes
removes the votes when transferring the tokens away from the voter.
If there's a secondary market for the pool tokens that allows flashloans, this means a lender (for example, the borrower could have approved itself before) can flashloan this amount, extend their repayment duration, and pay back the flashloan.
Use a snapshot-based voting system like the Comp
token does, or ungrant the extension in removeVotes
if it doesn't reach the threshold anymore.
#0 - ritik99
2021-12-24T14:48:27Z
An extension can only be granted once for a particular loan, so this attack cannot be replayed, and there are no assets at risk. We would suggest a (1) Low-Risk factor for this issue
#1 - 0xean
2022-01-21T20:47:41Z
Downgrading to low risk based on the fact that the lenders would have to willingly lend out their tokens in order for this to happen so they in fact control the attack vector.
1 β Low: Low: Assets are not at risk. State handling, function incorrect as to spec, issues with comments.
π Selected for report: cmichel
281.6754 USDC - $281.68
cmichel
Direct deposits to the savings account from the pool that don't have a strategy take the following path:
Pool.depositCollateral -> Pool._deposit(..., _toSavingsAccount=true, strategy=poolConstants.poolSavingsStrategy) -> SavingsAccountUtils.directDeposit -> SavingsAccountUtils.directSavingsAccountDeposit -> SavingsAccount.deposit -> SavingsAccount._deposit -> SavingsAccount._depositToYield -> _strategy.lockTockens
While directSavingsAccountDeposit
has a special case for _strategy == address(0)
, the _depositToYield
function ignores it and reverts in the registry check or when calling lockTokens
on the zero address.
function _depositToYield( uint256 _amount, address _token, address _strategy ) internal returns (uint256 _sharesReceived) { // @audit-info sometimes this is called with _strategy == 0 from `directSavingsAccountDeposit` require(IStrategyRegistry(strategyRegistry).registry(_strategy), 'SavingsAccount::deposit strategy do not exist'); uint256 _ethValue; if (_token == address(0)) { _ethValue = _amount; require(msg.value == _amount, 'SavingsAccount::deposit ETH sent must be equal to amount'); } // @audit-ok if strategy is attacker controlled, can mint billion shares. checks strategy here, but not everywhere? actually everywhere it gets increased _sharesReceived = IYield(_strategy).lockTokens{value: _ethValue}(msg.sender, _token, _amount); }
One cannot deposit to the savings account if the pool.SavingsStrategy
is zero.
Handle the case where _strategy == 0
in _deposit
or _depositToYield
.
#0 - ritik99
2021-12-27T06:46:45Z
the _strategy == address(0)
check in directSavingsAccountDeposit() is remnant of old code, we'll be removing it since it's an invalid case. We suggest reducing severity to (1) Low-risk since _strategy == address(0)
would have failed in the subsequent calls as expected, so there are no loss of funds
#1 - 0xean
2022-01-21T20:49:41Z
lower severity due to the revert eventually occurring.
126.7539 USDC - $126.75
cmichel
When transferring pool tokens to oneself the Pool._beforeTokenTransfer
overwrites the effectiveInterestWithdrawn
of the user with a higher amount than expected.
It uses the previous balance + the transfer amount instead of just the previous balance:
// @audit if from == to: overwrites with last _to statement => bug lenders[_from].effectiveInterestWithdrawn = (_fromBalance.sub(_amount)).mul(_totalRepaidAmount).div(_totalSupply); lenders[_to].effectiveInterestWithdrawn = (_toBalance.add(_amount)).mul(_totalRepaidAmount).div(_totalSupply);
The bug is not in the user's favor and would lead to them being able to withdraw fewer repayments in the future.
Pool.transfer(from=user, to=user, amount=pool.balanceOf(user))
_withdrawRepayment
calls. (second one does not lead to a second withdrawal as the effectiveInterestWithdrawn
is already increased in the first call)lenders[user].effectiveInterestWithdrawn
is then set using 2 * userBalance
.We still recommend fixing this bug, for example, by disallowing self-transfers.
π Selected for report: cmichel
281.6754 USDC - $281.68
cmichel
Certain ERC20 tokens make modifications to their ERC20's transfer
or balanceOf
functions.
One type of these tokens is deflationary tokens that charge a certain fee for every transfer()
or transferFrom()
.
The CreditLine._depositCollateral
function does not work well with fee-on-transfer tokens. An _amount
is first transferred to this address and then a strategy is approved with the same amount.
However, when the strategy tries to transfer this _amount
from the pool, it will fail as the pool received less than _amount
due to the fee.
One possible mitigation is to measure the asset change right before and after the asset-transferring calls.
#0 - ritik99
2022-01-07T23:02:56Z
We're aware that the current logic does not support deflationary assets. We maintain a token whitelist that enables us to choose which assets are used and which aren't, thus we suggest a (1) Low-Risk severity
#1 - 0xean
2022-01-21T21:05:58Z
Downgrading to sev-1 based on token whitelist
π Selected for report: cmichel
281.6754 USDC - $281.68
cmichel
The strategy contracts define an unlockShares
function that must accept an asset
parameter as the share token (yield token, aToken, cToken, etc.), otherwise, the code does not work.
However, all comments say that asset
is the address of the underlying token.
/** * @notice Used to unlock shares * @param asset the address of underlying token * @param amount the amount of shares to unlock * @return received amount of shares received **/ function unlockShares(address asset, uint256 amount) external override onlySavingsAccount nonReentrant returns (uint256) { if (amount == 0) { return 0; } }
Fix the comments for all unlockShares
by saying asset
is the share token, not the underlying token.
π Selected for report: sirhashalot
Also found by: 0x0x0x, WatchPug, cmichel, pmerkleplant, robee
cmichel
The function computes uint256 _amount = balanceInShares[msg.sender][_token][_strategyList[i]];
but this value is never used and reassigned in the next line to _amount = IYield(_strategyList[i]).unlockTokens(_token, balanceInShares[msg.sender][_token][_strategyList[i]]);
.
The first line can therefore be removed.
#0 - ritik99
2022-01-07T22:47:54Z
Duplicate of #36 since both issues deal with unnecessary initializations. Hence, the issue is the same even if the instances mentioned in both the places are different
π Selected for report: cmichel
83.4956 USDC - $83.50
cmichel
The if
conditions in Pool.withdrawLiquidity
are distinct conditions on the pool status.
Therefore, else if
is semantically equivalent but more gas efficient.
if (_loanStatus == LoanStatus.DEFAULTED || _loanStatus == LoanStatus.TERMINATED) { uint256 _totalAsset; if (poolConstants.borrowAsset != address(0)) { _totalAsset = IERC20(poolConstants.borrowAsset).balanceOf(address(this)); } else { _totalAsset = address(this).balance; } //assuming their will be no tokens in pool in any case except liquidation (to be checked) or we should store the amount in liquidate() _toTransfer = _toTransfer.mul(_totalAsset).div(totalSupply()); } // @audit gas: use else if, status fields are distinct, only one of the branches is (if ever) executed anyway if (_loanStatus == LoanStatus.CANCELLED) { _toTransfer = _toTransfer.add(_toTransfer.mul(poolVariables.penaltyLiquidityAmount).div(totalSupply())); } if (_loanStatus == LoanStatus.CLOSED) { //transfer repayment _withdrawRepayment(msg.sender); }