Vault Token
is not Want Token
. Specifically, the functions that are related to balance.Vault Token
and Want Token
are the sameYearn is a fascinating system that aims to provide users with excellent yields. To stay on top of the game, YearnV2 is extensible and allows new strategies to be deployed and applied.
We were fascinated by the concept and decided to do some research into the contracts.
In the yVault
/ Controller
/ Strategy
, there are three kinds of tokens:
yVault
. This will be converted into Want Token
Strategy
uses to obtain the target yield. During harvest, Strategy
will sell all the pool reward tokens into want tokens.One of the most important things in a protocol is how to distribute profits to users. In YearnV2, when users deposit funds with vault tokens
, they would get some shares. As users withdraw, they would burn the shares and supposedly get their tokens and profits back. Thus, we looked into the functions that are related to share calculation.
We identified 4 issues. One of which is valid but it only affects new vaults. Others are related to the fact that the implementation of the yCurve yVault/Strategy pair does not consider the case if Vault Token
is not Want Token
.
According to Andre, this is a design choice and different strategies should implement those functions differently.
However, we still wanted to leave the analysis here to show that what would happen IF theVault Token
and Want Token
are different in value and the functions are not modified.
Let's take a look at the deposit()
below. According to our understanding, the _pool = balance()
should have represented the amount of token this sytem holds. (p.s. we would later show that balance()
here is also incorrect.)
So, from the line if(_pool == 0)
, we can see that the contract assumes that:
if there is some balance in the system, then it is not a new vault anymore.
function deposit(uint _amount) external {
uint _pool = balance();
token.safeTransferFrom(msg.sender, address(this), _amount);
uint shares = 0;
if (_pool == 0) {
shares = _amount;
} else {
shares = (_amount.mul(totalSupply())).div(_pool);
}
_mint(msg.sender, shares);
}
However, this assumption can be easily broken.
As soon as a new vault is being deployed, an attacker can poison this baby vault by either sending some Want Token
to the underlying Strategy
or sending some Vault Token
to the yVault
. Later when a user comes in and tries to deposit()
, it will execute the else
branch:
shares = (_amount.mul(totalSupply())).div(_pool);
totalSupply()
here is the number of shares minted before and would be 0
. Thus, the shares
would be 0
as well.A poisoned vault will not give users any share and consequently the user's funds will be locked inside forever
I agree on the exploit, but the exploit is probability wise very unlikely, since;
- The strategy needs to be deployed
- The strategy needs to be linked to the 'want' in controller
- The vault needs to be deployed
- There needs to be no deposits in the vault
- The vault needs to be added in the controller
And then sending something to either vault or controller directly will brick the totalSupply calculation. This is more to do with the rollout system than anything else. Currently it can't actually do any damage if you send funds to the contract
It is very important to align the units of the interface together. NASA once lost a spacecraft, only because some parts of the system used English measurement and other parts used metric system. We have identified several ways of how the system would fail if the Vault Token
were to be different from the Want Token
.
We want to stress again that the issues described here are NOT present in the current yCurve Vault (as of August 4th 12:00pm EST
). The Vault Token
and Want Token
are the same in the present version.
Yes, they can be different, but if they are different, that specific strategy reports
balanceOf
differently. Balance of isn't copy/paste for all strategies
Still, we think it would be beneficial for the community to highlight these issues for future developers. Be extra careful when two tokens are different and make sure you have tweaked the functions.
When Vault Token
is different from Want Token
, it is possible that the user will not receive anything when he withdraws.
Looking at the withdraw()
in yVault.sol
, we see in the if (b < r) { ... }
that the contract attempts to withdraw from the underlying strategy if it does not have enough Vault Token
in the current Vault contract.
function withdraw(uint _shares) external {
uint r = (balance().mul(_shares)).div(totalSupply());
_burn(msg.sender, _shares);
// Check balance
uint b = token.balanceOf(address(this));
if (b < r) {
uint _withdraw = r.sub(b);
Controller(controller).withdraw(address(token), _withdraw);
uint _after = token.balanceOf(address(this));
uint _diff = _after.sub(b);
if (_diff < _withdraw) {
r = b.add(_diff);
}
}
token.safeTransfer(msg.sender, r);
}
Now let's take a closer look into how this line works:
Controller(controller).withdraw(address(token), _withdraw);
This calls the withdraw()
in Controller
with the token
being the Vault Token
.
// In Controller.sol
function withdraw(address _token, uint _amount) public {
require(msg.sender == vaults[_token], "!vault");
Strategy(strategies[_token]).withdraw(_amount);
}
and passes on to withdraw()
in the Strategy
. Note that:
Vault Token
]_token
is not passed into Strategy
. // In StrategyYfii.sol
// Withdraw partial funds, normally used with a vault withdrawal
function withdraw(uint _amount) external {
require(msg.sender == controller, "!controller");
uint _balance = IERC20(want).balanceOf(address(this));
if (_balance < _amount) {
_amount = _withdrawSome(_amount.sub(_balance));
_amount = _amount.add(_balance);
}
address _vault = Controller(controller).vaults(address(want));
require(_vault != address(0), "!vault"); // additional protection so we don't burn the funds
IERC20(want).safeTransfer(_vault, _amount);
}
If Vault Token
is not the same as Want Token
, There are at least two issues here:
_vault
is obtained with vaults[Want Token
], which is different from vaults[Vault Token
].Want Token
to the _vault
.Consequently, the original yVault
would NOT receive any token and the wrong yVault
would receive some Want Tokens
.
The function balance()
in yVault.sol
is used in both deposit()
and withdraw()
to calculate users' shares. This is how it looks like:
// In yVault.sol
function balance() public view returns (uint) {
return token.balanceOf(address(this))
.add(Controller(controller).balanceOf(address(token)));
}
We can see that it adds two balances together. But what tokens do they represent respectively? After tracing the code, we concluded that:
Controller(controller).balanceOf(address(token))
represents the amount of Want Token
that the Strategy
contract holds.token.balanceOf(address(this))
represents the amount of Vault Token
that this contract holds.Ooops! Seems like here we added two different things here.
Based on the Issue 3
, there is a more complicated and profitable exploit which we call the "dilution exploit". An attacker can transfer Want Token
to the Strategy
contract and incorrectly increase the balance
of the Vault
contract. Whoever deposits after the transfer would get less shares than one is supposed to and thus "diluted". While the victims would not lose their funds or shares, they would get less shares than expected and thus earn less profit in the future.
The profits that they missed are distributed evenly to previous share holders and thus the attacker would gain more profit.
If this extra profit is greater than the value of Want token
the attacker deposits into strategy contract, the attacker is incentivized to perform the attack. Again, we stress that this exploit only works when the Vault token:Want token != 1:1
. Thus, the current StrategyYfii
and StrategyYffi
are not vulnerable.
We provide a simple example with two scenarios to show this exploit. The two scenarios described below are based on the following settings:
Vault Token
is worth 1000
times the Want Token
We can see that Bob only gets 9.99
shares as opposed to the 10000
shares during the normal transaction. While Bob will not lose his original deposit, his profits are partially taken away by the attacker Alice.
For a more generalized version of this exploit, formal mathematical calculation is provided in this link for better latex support. It provides the precise equation to check if this type of exploit is incentivized to exist.
balance = balanceOfCurve + balanceOfYfii
?!When reading the contract StrategyYfii
, we noticed something odd when it is calculating the balance:
function balanceOf() public view returns (uint) {
return balanceOfCurve()
.add(balanceOfYfii());
}
Adding balance from two different coins seems unintuitive at best, and is possibly an error. Here are the two functions:
function balanceOfCurve() public view returns (uint) {
return IERC20(want).balanceOf(address(this));
}
balanceOfCurve()
is pretty straghtforward and represents how much yCRV
the strategy contract owns.
function balanceOfYfii() public view returns (uint) {
return Yfii(pool).balanceOf(address(this));
}
At first sight, it appeared to us that it is an ERC20
and reperesents how much Yfii
that the strategy contract owns. We've contacted Andre to confirm about this issue, and thankfully, it is actually not the case.
So, balanceOfYfii()
is less intuitive – The balanceOf()
here is actually how much yCRV
has the strategy contract staked into the yearnRewards pool.
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function stake(uint256 amount) public {
_totalSupply = _totalSupply.add(amount);
_balances[msg.sender] = _balances[msg.sender].add(amount);
y.safeTransferFrom(msg.sender, address(this), amount);
}
Thus, the StrategyYfii
acts correctly and we are safe here.
Blockchain