CompoundStrategy _currentBalance uses exchangeRateStored which is leaks value
Lines of code
https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L131-L137 https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L128
Vulnerability details
Impact
YieldBox determines the amount of shares to mint based on the totalSupply and the strategy.currentBalance()
solidity/// @dev Returns the total balance of `token` the strategy contract holds, /// plus the total amount this contract thinks the strategy holds. function _tokenBalanceOf(Asset storage asset) internal view returns (uint256 amount) { return asset.strategy.currentBalance(); }
The CompoundStrategy _currentBalance is relying on a stale exchangeRate, opening up YieldBox to MEV attacks that can steal all of the Yield
solidityfunction _currentBalance() internal view override returns (uint256 amount) { uint256 shares = cToken.balanceOf(address(this)); uint256 pricePerShare = cToken.exchangeRateStored(); /// @audit stale rate leaks value uint256 invested = (shares * pricePerShare) / (10 ** 18); uint256 queued = wrappedNative.balanceOf(address(this)); return queued + invested; }
POC
The Yield Generated by the strategy is equal to: total deposits * increase in exchange rate
Because the strategy is using exchangeRateStored instead of the latest one, the delta between exchangeRateStored and the most up to date one can be MEVd away via the following:
- Deposit into the Strategy at old rate
- Trigger cToken rate update (should happen before mint)
- Withdraw from strategy
The deposit will use the old _currentBalance while the withdrawal will use the latest exchangeRate which means it will offer out a pro-rata portion of the Yield
solidity/// @audit Pay for shares in Yield Box if (share == 0) { // value of the share may be lower than the amount due to rounding, that's ok share = amount._toShares(totalSupply[assetId], totalAmount, false); } else { // amount may be lower than the value of share due to rounding, in that case, add 1 to amount (Always round up) amount = share._toAmount(totalSupply[assetId], totalAmount, true); }
solidity// Mint cToken.mint{value: queued}(); emit AmountDeposited(queued); /// @audit Which uses updated rate, causing a loss of Yield to Everyone else
The logical extreme is to deposit via a flashLoan and steal 99.9% of the Yield each time this is profitable.
This will cause Honest Depositors to socialize away their yield, at the advantage of MEV attackers
Coded POC
This POC is written in foundry, to run it use
forge test --match-test testCompTokenMathMEV -vv
The outputt shows that while the cToken rate has changed, the Strategy will still be underpricing it
This allows a new depositor to receive more shares than intended when depositing, while contributing to a lower amount of shares in the YieldBox, which will cause everyone else to lose on that Yield
Output:
pythonLogs: initialShares 100000000000000000000 initialRate 1000000000000000000 initialBalance 100000000000000000000 updatedBalance 100000000000000000000 updatedRate 1100000000000000000 secondDepositShares 90909090909090909090
solidity// SPDX-License Identifier: MIT pragma solidity ^0.8.0; import "forge-std/Test.sol"; import "forge-std/console2.sol"; import "./tERC20.sol"; contract MockCompToken { uint256 public underlying; uint256 public shares; uint256 public exchangeRateStored; function latestExchangeRate() public view returns (uint256) { if(shares == 0) { return 1e18; } return 1e18 * underlying / shares; } function addYield(uint256 amt) public { underlying += amt; } function deposit(uint256 amt) external returns (uint256) { uint256 rate = latestExchangeRate(); exchangeRateStored = rate; uint256 newShares = amt * 1e18 / rate; underlying += amt; shares += newShares; return newShares; } function balanceOf(address) external view returns (uint256) { return shares; } } contract MockStrategy { MockCompToken cToken; constructor(MockCompToken _cToken) { cToken = _cToken; } function currentBalance() public view returns (uint256 amount) { uint256 shares = cToken.balanceOf(address(this)); uint256 pricePerShare = cToken.exchangeRateStored(); /// @audit stale rate leaks value uint256 invested = (shares * pricePerShare) / (10 ** 18); // uint256 queued = wrappedNative.balanceOf(address(this)); // Static Value return invested; } } contract CompoundedStakesFuzz is Test { MockStrategy mockStrategy; MockCompToken compToken; function setUp() public { compToken = new MockCompToken(); mockStrategy = new MockStrategy(compToken); } function testCompTokenMathMEV() public { // Initial 1/1 deposit uint256 initialShares = compToken.deposit(100e18); console2.log("initialShares", initialShares); assertEq(initialShares, 100e18); // 1 to 1 uint256 initialRate = compToken.latestExchangeRate(); console2.log("initialRate", initialRate); assertEq(initialRate, 1e18); // 1e18 is base rate // Strategy bal uint256 initialBalance = mockStrategy.currentBalance(); console2.log("initialBalance", initialBalance); assertEq(initialBalance, 100e18); // 1 to 1 // 10% yield compToken.addYield(10e18); // 10% uint256 updatedBalance = mockStrategy.currentBalance(); console2.log("updatedBalance", updatedBalance); assertEq(initialBalance, updatedBalance, "Balance in strat"); // Didn't Change uint256 updatedRate = compToken.latestExchangeRate(); console2.log("updatedRate", updatedRate); assertTrue(updatedRate > initialRate, "Rate increased"); // 1e18 is base rate uint256 secondDepositShares = compToken.deposit(100e18); console2.log("secondDepositShares", secondDepositShares); assertTrue(secondDepositShares < initialShares, "shares decreas"); // We got less than expected } }
Mitigation
Use https://github.com/transmissions11/libcompound to use the latest exchange rate which will prevent any form of value leak in the exchange rate
Assessed type
ERC4626
