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: 2/12
Findings: 12
Award: $8,039.91
🌟 Selected for report: 10
🚀 Solo Findings: 3
209.319 YAXIS - $816.34
jonah1005
In controller.sol's function setCap
, the contract wrongly handles _vaultDetails[_vault].balance
. While the balance should be decreased by the difference of strategies balance, it subtracts the remaining balance of the strategy.
Controller.sol#L262-L278
_vaultDetails[_vault].balance = _vaultDetails[_vault].balance.sub(_balance);
This would result in vaultDetails[_vault].balance
being far smaller than the strategy's value. A user would trigger the assertion at Contreller.sol#475 and the fund would be locked in the strategy.
Though setCap
is a permission function that only the operator can call, it's likely to be called and the fund would be locked in the contract. I consider this a high severity issue.
We can trigger the issue by setting the cap 1 wei smaller than the strategy's balance.
strategy_balance = strategy.functions.balanceOf().call() controller.functions.setCap(vault.address, strategy.address, strategy_balance - 1, dai.address).transact() ## this would be reverted vault.functions.withdrawAll(dai.address).transact()
Hardhat
I believe the dev would spot the issue in the test if _vaultDetails[_vault].balance
is a public variable.
One possile fix is to subtract the difference of the balance.
uint previousBalance = IStrategy(_strategy).balanceOf(); _vaultDetails[_vault].balance.sub(previousBalance.sub(_amount));
#0 - transferAndCall
2021-09-12T15:39:34Z
Please review https://github.com/code-423n4/2021-09-yaxis/pull/1 to verify resolution.
#1 - GalloDaSballo
2021-10-12T23:31:17Z
High risk vulnerability due to incorrect logic which can impact protocol functionality
Sponsor has mitigated
125.5914 YAXIS - $489.81
jonah1005
There's no safety check in Manager.sol
addToken
. There are two possible cases that might happen.
One token being added twice in a Vault. Token would be counted doubly in the vault. Ref: Vault.sol#L293-L303. There would be two item in the array when querying manager.getTokens(address(this));
.
A token first being added to two vaults. The value calculation of the first vault would be broken. As vaults[_token] = _vault;
would point to the other vault.
Permission keys should always be treated cautiously. However, calling the same initialize function twice should not be able to destroy the vault. Also, as the protocol develops, there's likely that one token is supported in two vaults. The DAO may mistakenly add the same token twice. I consider this a high-risk issue.
Adding same token twice would not raise any error here.
manager.functions.addToken(vault.address, dai.address).transact() manager.functions.addToken(vault.address, dai.address).transact()
Hardhat
I recommend to add two checks
require(vaults[_token] == address(0)); bool notFound = True; for(uint256 i; i < tokens[_vault].length; i++) { if (tokens[_vault] == _token) { notFound = False; } } require(notFound, "duplicate token");
#0 - transferAndCall
2021-09-12T16:15:56Z
Please review https://github.com/code-423n4/2021-09-yaxis/pull/2 to verify resolution.
#1 - GalloDaSballo
2021-10-13T23:23:20Z
Lack of check for duplicates can cause undefined behaviour, sponsor mitigated by adding a require check
125.5914 YAXIS - $489.81
jonah1005
When a user tries to withdraw the token from the vault, the vault would withdraw the token from the controller if there's insufficient liquidity in the vault. However, the controller does not raise an error when there's insufficient liquidity in the controller/ strategies. The user would lose his shares while getting nothing.
An MEV searcher could apply this attack on any withdrawal. When an attacker found an unconfirmed tx that tries to withdraw 1M dai, he can do such sandwich attack.
All users would be vulnerable to MEV attackers. I consider this is a high-risk issue.
Here's web3.py script to reproduce the issue.
deposit_amount = 100000 * 10**18 user = w3.eth.accounts[0] get_token(dai, user, deposit_amount) dai.functions.approve(vault.address, deposit_amount + margin_deposit).transact() vault.functions.deposit(dai.address, deposit_amount).transact() vault.functions.withdrawAll(usdt.address).transact() # print("usdt amount: ", usdt.functions.balanceOf(user).call())
None
There are two issues involved. First, users pay the slippage when they try to withdraw. I do not find this fair. Users have to pay extra gas to withdraw liquidity from strategy, convert the token, and still paying the slippage. I recommend writing a view function for the frontend to display how much slippage the user has to pay. Controler.sol#L448-L479
Second, the controller does not revert the transaction there's insufficient liquidity.
Controller.sol#L577-L622
Recommend to revert the transaction when _amount
is not equal to zero after the loop finish.
#0 - GalloDaSballo
2021-10-14T13:58:01Z
Agree with warden finding, this shows the path for an attack that is based on the Vault treating all tokens equally Since the finding shows a specific attack, the finding is unique
Recommend the sponsor mitigates Single Sided Exposure risks to avoid this attack
125.5914 YAXIS - $489.81
jonah1005
The v3 vault treats all valid tokens exactly the same. Depositing 1M DAI would get the same share as depositing 1M USDT. User can withdraw their share in another token. Though there's withdrawalProtectionFee
(0.1 percent), the vault is still a no slippage stable coin exchange.
Also, I notice that 3crv_token is added to the vault in the test. Treating 3crv_token and all other stable coins the same would make the vault vulnerable to flashloan attack. 3crv_token is an lp token and at the point of writing, the price of it is 1.01. The arbitrage space is about 0.8 percent and makes the vault vulnerable to flashloan attacks.
Though the team may not add crv_token and dai to the same vault, its design makes the vault vulnerable. Strategies need to be designed with super caution or the vault would be vulnerable to attackers.
Given the possibility of flashloan attack, I consider this a high-risk issue.
The issue locates at the deposit function. Vault.sol#L147-L180
The share is mint according to the calculation here
_shares = _shares.add(_amount);
The share is burned at Vault.sol#L217
uint256 _amount = (balance().mul(_shares)).div(totalSupply());
Here's a sample exploit in web3.py.
deposit_amount = 100000 * 10**6 user = w3.eth.accounts[0] get_token(usdt, user, deposit_amount) usdt.functions.approve(vault.address, deposit_amount).transact() vault.functions.deposit(usdt.address, deposit_amount).transact() vault.functions.withdrawAll(t3crv.address).transact() # user can remove liquiditiy and get the profit.
Hardhat
Given the protocols' scenario, I feel like we can take iearn token's architect as a Ref. yDdai
yDai handles multiple tokens (cDai/ aDai/ dydx/ fulcrum). Though four tokens are pretty much the same, the contract still needs to calculate the price of each token.
Or, create a vault for each token might be an easier quick fix.
#0 - transferAndCall
2021-09-12T15:44:35Z
The design of the v3 vaults is to intentionally assume that all allowed tokens are of equal value. I do not see us enabling the 3CRV token in our Vault test, though if we did, that doesn't mean we would in reality. Using a separate vault per token is an architecture we want to avoid.
#1 - GalloDaSballo
2021-10-13T15:50:32Z
Anecdotal example from warden makes sense.
Assuming that 3CRV is worth the same as a stablecoin is in principle very similar to assuming that a swap between each stable on curve will yield a balanced trade
This reminds me of the Single Sided Exposure Exploit that Yearn Suffered, and would recommend mitigating by checking the virtual_price on the 3CRV token
#2 - GalloDaSballo
2021-10-13T15:50:52Z
TODO: Review and check duplicates, need to read yaxis vault code and use cases before can judge this
#3 - GalloDaSballo
2021-10-14T14:33:36Z
After reviewing the code and the submissions, I have to agree that the vault creates arbitrage opportunities, since it heavily relies on 3CRV you may want to use it's virtual_price
as a way to mitigate potential exploits, alternatively you can roll your own pricing oracle solution
Not mitigating this opportunity means that an attacker will exploit it at the detriment of the depositors
🌟 Selected for report: jonah1005
465.1532 YAXIS - $1,814.10
jonah1005
For a dai vault that pairs with NativeStrategyCurve3Crv, every time earn()
is called, shareholders would lose money. (about 2%)
There're two issues involved. The Vault contract and the controller contract doesn't handle the price difference between the want token and other tokens.
Vault.sol#L293 When a vault calculates its value, it sums up all tokens balance. Controller.sol#L410-L436 However, when the controller calculates vaults' value, it only adds the amount of strategy.want
it received. (in this case, it's t3crv).
Under the current design, users who deposit dai to the vault would not get yield. Instead, they would keep losing money. I consider this a high-risk issue
I trigger the bug with the following web3.py script:
previous_price = vault.functions.getPricePerFullShare().call() vault.functions.available(dai.address).call() vault.functions.earn(dai.address, strategy.address).transact() current_price = vault.functions.getPricePerFullShare().call() print(previous_price) print(current_price)
Hardhat
The protocol should decide what the balance sheet in each contract stands for and make it consistent in all cases. Take, for example, if _vaultDetails[_vault].balance;
stands for the amount of 'want' token the vault owns, there shouldn't exist two different want in all the strategies the vault has. Also, when the vault queries controllers function balanceOf()
, they should always multiply it by the price.
#0 - gpersoon
2021-09-30T11:50:57Z
I think this is also related to the underlying problem that all coins are assumed to have the same value. See also #2, #8 and #158
#1 - GalloDaSballo
2021-10-14T15:53:10Z
Agree with wardens finding and acknowledge it's similitude with other issues
Personally this is a different vulnerability that can be solved by solving the same underlying problem
Marking this as unique finding as it's a specific exploit the protocol could face
jonah1005
The vault does not normalize decimals when a user withdraws the token. When a user has 100e18 shares, he can withdraw all usdc/ usdt from the token.
The liquidity of USDC/USDC would be drained. I consider this is a high-risk issue.
This is the web3.py script to drain all usdt from the vault.
usdt_balance = usdt.functions.balanceOf(vault.address).call() print('previous usdt:', usdt_balance) # We deposit some margin dai to drain all liquidity. deposit_amount = usdt_balance + usdt_balance dai.functions.approve(vault.address, deposit_amount).transact() vault.functions.deposit(dai.address, deposit_amount).transact() print('amount of share:', vault.functions.balanceOf(user).call()) vault.functions.withdrawAll(usdt.address).transact() ## usdt would left zero print('usdt left:', usdt.functions.balanceOf(vault.address).call())
None
Normalize decimals on withdrawing.
#0 - gpersoon
2021-09-30T13:01:36Z
Seems duplicate of #73
#1 - GalloDaSballo
2021-10-14T16:52:49Z
Duplicate of #131
🌟 Selected for report: jonah1005
465.1532 YAXIS - $1,814.10
jonah1005
There's no safety check in Manager.sol's removeToken. Manager.sol#L454-L487
_vaultDetails[msg.sender].balance;
remains the same, user can nolonger withdraw those amount.totalValue / totalSupply
Vault.sol#L217. While the totalSupply
of the share remains the same, the total balance has drastically decreased.Calling removeToken way would almost break the whole protocol if the vault has already started. I consider this is a high-risk issue.
We can see how the vault would be affected with below web3.py script.
print(vault.functions.balanceOfThis().call()) print(vault.functions.totalSupply().call()) manager.functions.removeToken(vault.address, dai.address).transact() print(vault.functions.balanceOfThis().call()) print(vault.functions.totalSupply().call())
output
100000000000000000000000 100000000000000000000000 0 100000000000000000000000
Hardhat
Remove tokens from a vault would be a really critical job. I recommend the team cover all possible cases and check all components' states (all vault/ strategy/ controller's state) in the test.
Some steps that I try to come up with that is required to remove TokenA from a vault.
#0 - transferAndCall
2021-09-12T17:25:11Z
Removing a token is understood as a critical (and possibly nuclear) operation within this architecture. We knew we would have to first withdraw all of the identified token from strategies, but what was missed was converting that token to another (without withdrawing, as that would be too much centralization).
Proposed method of resolution:
#1 - transferAndCall
2021-09-12T19:23:35Z
Please review https://github.com/code-423n4/2021-09-yaxis/pull/5 to check resolution.
#2 - GalloDaSballo
2021-10-14T16:27:15Z
Removing a token can cause accounting errors, stuck funds and break some of the functionality
Adding additional checks to prevent removing the token until all tokens have been migrated may be the simplest path forward
Sponsor has mitigated by adding custom functionality, however it is up to them to enforce that the vault has no token left before removing it, adding a couple extra checks may provide a guarantee against admin privileged abuses
125.5914 YAXIS - $489.81
jonah1005
The Controller's function withdraw(address _token, uint256 _amount)
should return whatever amount of the token user/vault asks. However, it tries to withdraw strategy.want
token and convert it.
Take for example, when a user/vault calls withdraw(dai, 100)
, the controller should transfer 100 dai
to the user/vault; instead, it withdraw 100 t3crv
from the strategy, convert it to dai, and transfer the converted amount to the user. In this case, the user would get about 101 dai. (1 t3crv = 1.01 dai).
1 percent of arbitrage space is enough to cause painful results. Also, the arbitrage space really depends on the strategy. Strategy should be able to have whatever want token, e.g., ETH, CRV,.... Vault users would definetly lose their money if the price not being handled properly.
I consider this a high-risk issue.
We can trigger the bug with following web3.py script.
deposit_amount = 100000 * 10**18 user = w3.eth.accounts[0] get_token(dai, user, deposit_amount) dai.functions.approve(vault.address, deposit_amount).transact() vault.functions.deposit(dai.address, deposit_amount).transact() deposit_amount = 100000 * 10**18 user2 = w3.eth.accounts[1] get_token(dai, user2, deposit_amount) dai.functions.approve(vault.address, deposit_amount).transact({'from': user2}) vault.functions.deposit(dai.address, deposit_amount).transact({'from': user2}) vault.functions.earn(dai.address, strategy.address).transact() vault.functions.withdrawAll(dai.address).transact({'from': user}) ## raise error here vault.functions.withdrawAll(dai.address).transact({'from': user2})
Since the first user gets more tokens than he should have, user2 would not be able to withdraw all the shares.
None
#0 - gpersoon
2021-09-30T11:47:21Z
I think this is related to the underlying problem that all coins are assumed to have the same value. See also #2 and #158
#1 - GalloDaSballo
2021-10-14T16:48:36Z
Duplicate of #77
37.6774 YAXIS - $146.94
jonah1005
Manager contract does not use safeTransfer in function recoverToken
. Mansger.sol#L442-L452
Manager contract would not be able to transfer non-standard ERC20 tokens.
Given the manager is not designed to hold the token, I consider this a low-risk issue.
None
I recommend using safeERC20 as other contracts do.
#0 - transferAndCall
2021-09-12T16:29:51Z
Please review https://github.com/code-423n4/2021-09-yaxis/pull/3 and verify resolution.
#1 - GalloDaSballo
2021-10-13T23:04:39Z
Duplicate of #114
Raising to Med severity
🌟 Selected for report: jonah1005
139.546 YAXIS - $544.23
jonah1005
The protocol frequently interacts with crv a lot. However, the contract doesn't specify the minimum return amount. Given the fact that there's a lot of MEV searchers, calling swap without specifying the minimum return amount really puts user funds in danger.
For example, controller's withdrawAll
is designed to transfer all the funds in a strategy.Controller.sol#L360 The arbitrage space is enough for a searcher to sandwich this trade.
None
Always calculates an estimated return when calling to crv.
#0 - GalloDaSballo
2021-10-14T00:31:14Z
Agree with finding, agree with severity as this allows to "leak value"
🌟 Selected for report: jonah1005
46.5153 YAXIS - $181.41
jonah1005
There's no safety check in controller's addStrategy
. When the same strategy is added to a vault twice, the protocol breakdowns in several ways.
_vaultDetails[_vault].balances[_strategy]
would not track strategy's balance correctly; getBestStrategyWithdraw
would have a wrong answer and makes withdrawing from the strategy to raise error in certain scenarios.I consider this a low-risk issue.
This is the web3.py script:
controller.functions.addStrategy(vault.address, strategy.address, cap, 0).transact() controller.functions.addStrategy(vault.address, strategy.address, cap, 0).transact() # would not be able to removestrategy controller.functions.removeStrategy(vault.address, strategy.address, 0).transact()
Hardhat
The controller should raise an error if the strategy has been added to the protocol(any vault). As adding the same strategy to two different vaults would have worse results, the controller can maintain a map to record each strategy's status.
#0 - Haz077
2021-09-20T19:12:43Z
Edits are added in code-423n4/2021-09-yaxis#10
#1 - GalloDaSballo
2021-10-14T00:15:25Z
Sponsor agreed and mitigated, ensuring that a strategy is added only once avoids undefined logic errors
jonah1005
When a vault imposes a deposit cap check, it checks totalSupply
instead of balance
. This is under the assumption that the share price would be (close) 1. However, the price of shares would deviate from 1 after the vault's launch. Also, early vault users can pump share price by sending tokens to the vault.
This is the web3.py script to pump share price
deposit_amount = 100000 * 10**18 margin_deposit = 100 user = w3.eth.accounts[0] get_token(dai, user, deposit_amount + margin_deposit) dai.functions.approve(vault.address, deposit_amount + margin_deposit).transact() vault.functions.deposit(dai.address, margin_deposit).transact() dai.functions.transfer(vault.address, deposit_amount,).transact() share_price = vault.functions.getPricePerFullShare().call() print(share_price)
After pumping the share price, normal user can deposit beyond the vault's cap.
vault.functions.setTotalDepositCap(100*10**18).transact() deposit_amount = 100000 * 10**18 user = w3.eth.accounts[0] get_token(dai, user, deposit_amount) dai.functions.approve(vault.address, deposit_amount ).transact() vault.functions.deposit(dai.address, deposit_amount).transact()
Hardhat
Checks balance()
instead of totalSupply
#0 - Haz077
2021-09-30T17:47:11Z
Kinda same as #26, so I chose #26 because it's more detailed and both issues are from the same handle anyway.
#1 - GalloDaSballo
2021-10-14T00:21:14Z
The warden is claiming that the vault is not enforcing depositCaps because the require check is checking for totalSupply
instead of balance:
https://github.com/code-423n4/2021-09-yaxis/blob/7d253085b0109f48164362aa4dfecd46685627b4/contracts/v3/Vault.sol#L200
This is a valid (and unique) finding
🌟 Selected for report: jonah1005
46.5153 YAXIS - $181.41
jonah1005
When a user deposit tokens to the vault, the amount of share is calculated as _amount = (_amount.mul(totalSupply())).div(_balance);
Vault.sol#L192. The first depositor can hijack the vault by depositing 1 wei of the token and pump the price share by sending extra token. Any new user that deposits to the vault would get zero share as totalSupply() / balance()
would be rounded to zero.
Steps to exploit:
Since permissionless user would no be able to add new vault into the protocol, I consider this a low-risk issue.
This is the web3.py script to pump share price
deposit_amount = 100000 * 10**18 margin_deposit = 1 user = w3.eth.accounts[0] get_token(dai, user, deposit_amount + margin_deposit) dai.functions.approve(vault.address, deposit_amount + margin_deposit).transact() vault.functions.deposit(dai.address, margin_deposit).transact() dai.functions.transfer(vault.address, deposit_amount,).transact() share_price = vault.functions.getPricePerFullShare().call() print(share_price)
None
Common mitigation of this attack is to permanently lock some lp tokens at initialization. Ref: UniswapV2Pair.sol#L110-L131
#0 - GalloDaSballo
2021-10-13T23:06:44Z
Agree with griefing potential, would recommend making initial deposits of at least 1 * 10 ** DECIMALS of the vault token to avoid that scenario (or to make it expensive to perform)
2.7432 YAXIS - $10.70
jonah1005
[Vault.sol#L172] (https://github.com/code-423n4/2021-09-yaxis/blob/main/contracts/v3/Vault.sol#L172)
_shares = _shares.add(_amount);
_shares is always 0 before the operation
[Vault.sol#L172] (https://github.com/code-423n4/2021-09-yaxis/blob/main/contracts/v3/Vault.sol#L172)
_shares = _amount
#0 - transferAndCall
2021-09-12T16:34:03Z
Please review https://github.com/code-423n4/2021-09-yaxis/pull/4 to verify resolution.
#1 - GalloDaSballo
2021-10-12T22:26:16Z
Duplicate of #118