Skip to content

Vault is vulnerable to first depositor inflation attack. #39

@c4-bot-6

Description

@c4-bot-6

Lines of code

https://github.com/code-423n4/2024-05-bakerfi/blob/main/contracts/core/Vault.sol#L190-L227

Vulnerability details

Impact

First depositor can manipulate the price of the shares at will, forcing new depositors to deposit more ETH for the same amount of shares that the first depositor paid

Proof of Concept

Before diving into the details of how this attack is performed, let's understand how the Vault determines the amount of shares to mint for a deposited amount of ETH.

  1. When doing a deposit, the Vault creates a new variable of Rebase type by passing the resultant values of calling _totalAssets() and totalSupply() functions. By inpsecting the Rebase struct, we know that the totalAssets() will be the elastic portion, and the totalSupply() will be the base. Or in other words:
  • assets are elastic.
  • shares are the base
  1. Now, let's see what values are returned on each of the two functions that are called when the total variable of Rebase type is created.
  • _totalAssets() represents the amount of assets owned by the strategy. By own it means the difference between the collateral value in ETH and all the WETH debt. For example, if the total collateral value in ETH is 100 ETH, and the total WETH debt is 70 WETH, then, the totalAssets would return 30 ETH. See below the exact code that is used to calculate the totalAssets:
> Vault.sol

function _totalAssets(uint256 priceMaxAge) private view returns (uint256 amount) {
    //@audit-info => totalAssets is difference between totalCollateralInETh - totalDebtInEth owned by the Strategy!
    amount = _strategy.deployed(priceMaxAge);
}

> StrategyLeverage.sol
function deployed(uint256 priceMaxAge) public view returns (uint256 totalOwnedAssets) {

    //@audit-info => totalCollateralInEth is the value of the aTokenCollateral owned by the Strategy worth in ETH
    //@audit-info => totalDebtInETH is the WETH debt in Aave that was taken to repay the flashloans used for leverage!
    (uint256 totalCollateralInEth, uint256 totalDebtInEth) = _getPosition(priceMaxAge);

    //@audit-info => The returned value from the `deployed()` is the difference between totalCollateralInETh -totalDebtInEth
    totalOwnedAssets = totalCollateralInEth > totalDebtInEth
        ? (totalCollateralInEth - totalDebtInEth)
        : 0;
}

function _getPosition(
    uint256 priceMaxAge
) internal view returns (uint256 totalCollateralInEth, uint256 totalDebtInEth) {
    totalCollateralInEth = 0;
    totalDebtInEth = 0;

    //@audit-info => debtBalance is the amount of WETH DebtToken owned by the Strategy contract!
    //@audit-info => collateralBalance is the amount of Collateral aToken owned by the Strategy contract
    (uint256 collateralBalance,  uint256 debtBalance ) = _getMMPosition();

    if (collateralBalance != 0) {            
        ...

        //@audit-info => Computes the value of the aTokenCollateral worth in ETH
        totalCollateralInEth = (collateralBalance * collateralPrice.price) / ethPrice.price;
    }
    if (debtBalance != 0) {
        totalDebtInEth = debtBalance;
    }
}

> StrategyAAVEv3.sol
function _getMMPosition() internal virtual override view returns ( uint256 collateralBalance, uint256 debtBalance ) {
    DataTypes.ReserveData memory wethReserve = (aaveV3().getReserveData(wETHA()));
    DataTypes.ReserveData memory colleteralReserve = (aaveV3().getReserveData(ierc20A()));

    //@audit-info => debtBalance is the amount of WETH DebtToken owned by the Strategy contract!
    debtBalance = IERC20(wethReserve.variableDebtTokenAddress).balanceOf(address(this));

    //@audit-info => collateralBalance is the amount of Collateral aToken owned by the Strategy contract
    collateralBalance = IERC20(colleteralReserve.aTokenAddress).balanceOf(
        address(this)
    );
}

  • totalSupply() represents all the existing shares that have been minted for all the deposits that have been made in the Vault.
  1. Then, the execution runs a couple of checks to verify that the total Rebase variable's state is correct, and then it proceeds to call the StrategyLeverage.deploy() function, where it will do a leveraged deposit of the Strategy's collatelar (wstETH, rETH, cbETH) in Aave. To leverage the deposit, the Strategy requests a WETH flashloan on Balancer, swaps the borrowed and original deposit funds for collateral, deposits all the swapped collateral into Aave, and then it opens a WETH borrow for the exact amount to repay the flashloan to Balancer.
  1. Finally, with the returned value of the StrategyLeverage.deploy() function, the Vault computes the amount of shares to mint to the receiver for the deposited funds. The formula that is used to determine the shares is shares = (assets * totalSupply()) / totalAssets(), where assets is the amount of collateral in ETH deployed after leverage.
> Vault.sol

function deposit(
    address receiver
)
    ...
{
    ...

    //@audit-info => Step 1, creates a variable of Rebase type by passing as parameters the totalAssets() and totalSupply() of the Vault!
    Rebase memory total = Rebase(_totalAssets(maxPriceAge), totalSupply());
    
    ...

    //@audit-info => Step 3, the deposited amount is deployed on the Strategy!
    bytes memory result = (address(_strategy)).functionCallWithValue(
        abi.encodeWithSignature("deploy()"),
        msg.value
    );

    uint256 amount = abi.decode(result, (uint256));

    //@audit-info => Step 4, Computes the amount of shares to mint for the amount that was deployed after leverage on the Strategy
    shares = total.toBase(amount, false);
    _mint(receiver, shares);
    emit Deposit(msg.sender, receiver, msg.value, shares);
}

Now, time to analyze how the attack is performed:

  1. Alice is the first depositor in the Vault;
  2. Alice deposits 10 wei of ETH
  3. Since Alice is the first depositor (totalSupply is 0 && totalAssets is 0), she gets 10 weis of a share (10 wei)
  4. Alice then sends 99999999999999999999 (100e18 - 1) aCollateralToken to the Strategy; Where aCollateralToken is the aToken that Aave mints when the strategy deploys/supplies collateral to it.
  • There are now 10 weis of shares and a total of 100e18 aCollateralToken as totalAssets: Alice is the only depositor in the vault, she's holding 10 weis of shares, and the totalAssets is 100e18 aCollateralToken. For ease of calculations, suppose collateral per ETH is 1:1.
  1. Bob deposits 19 ETH and gets only 1 share due to the rounding down in the calculation to compute the shares: 19e18 * 10 / 100e18 == 10;
  2. Each Share will redeem: totalAssets / totalShares == `119e18 / 11 => 10.81e18 ETH in aCollateralToken
  3. The 10 wei of shares owned by Alice can claim: 108.1e18 ETH. Meaning, Alice can steal ~8 ETH from Bob's deposit.
  4. The 1 wei of Shares owned by Bob can only claim: 10.81 ETH. Meaning, Bob automatically lost ~8 ETH from the 19 ETH he just deposited.

The root cause that makes this attack possible is that the Vault's shares and assets are not initialized/seeded when the Vault is created & the fact that the totalAssets is dependant on the total aCollateralTokens the associated Strategy to the Vault is holding on its balance.

  • This allows an attacker to inflate the share-assets rate by transfering aCollateralToken directly to the Strategy. By doing this direct transfer, those aCollateralTokens will inflate the rate of the initial deposit made by the attacker, causing real depositors to deposit at an inflated rate, from which an attacker will profit by withdrawing the initial shares he minted for himself and withdrawing all his deposited (and direct transfered) aCollateralTokens + a portion of the deposited value from real depositors.

Tools Used

Manual Audit

Recommended Mitigation Steps

Consider either of these options:

  • Consider seeding the pools during deployment. This needs to be done in the deployment transactions to avoiding front-running attacks. The amount needs to be high enough to reduce the rounding error.
  • Consider sending first 1000 wei of shares to the zero address. This will significantly increase the cost of the attack by forcing an attacker to pay 1000 times of the share price they want to set. For a well-intended user, 1000 wei of shares is a negligible amount that won't diminish their share significantly.
  • Implement the concept of virtual shares, similar to the ERC4626 OZ contract. More info about this concept here

Assessed type

Other

Metadata

Metadata

Assignees

No one assigned

    Labels

    3 (High Risk)Assets can be stolen/lost/compromised directly🤖_22_groupAI based duplicate group recommendationH-02bugSomething isn't workingprimary issueHighest quality submission among a set of duplicatesselected for reportThis submission will be included/highlighted in the audit reportsponsor confirmedSponsor agrees this is a problem and intends to fix it (OK to use w/ "disagree with severity")

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions