Light ModeLight
Light ModeDark

One Bug Per Day

One H/M every day from top Wardens

Checkmark

Join over 445 wardens!

Checkmark

Receive the email at any hour!

Ad

CompoundStrategy _currentBalance uses exchangeRateStored which is leaks value

mediumCode4rena

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()

https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L102-L104

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

https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L113-L119

solidity
function _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

https://github.com/Tapioca-DAO/YieldBox/blob/f5ad271b2dcab8b643b7cf622c2d6a128e109999/contracts/YieldBox.sol#L131-L137

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); }

https://github.com/Tapioca-DAO/tapioca-yieldbox-strategies-audit/blob/05ba7108a83c66dada98bc5bc75cf18004f2a49b/contracts/compound/CompoundStrategy.sol#L128

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:

python
Logs: 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