First depositor can inflate share price and capture subsequent deposits
src/QuillonVault.sol:L188–L214
- Likelihood
- High
- Impact
- High
- Weakness
- CWE-682
- Description
Share minting uses
assets * totalSupply / totalAssets(), rounding down, with no virtual offset. When the vault is empty, the exchange rate is defined entirely by whatever the first depositor does.An attacker deposits 1 wei and receives one share. They then transfer a large amount of the asset directly to the vault without calling
deposit.totalAssets()now includes the donation whiletotalSupplyis still 1, so a single share is worth the entire balance.Any later deposit smaller than the donation computes
assets * 1 / (donation + 1), which rounds down to zero shares. The deposit is accepted, the depositor receives nothing, and the assets accrue to the attacker’s single share.- Preconditions
- The vault has zero or near-zero total supply (new deployment or fully drained vault)
- The attacker can front-run or precede the first legitimate deposit
- The vault does not revert on zero-share mints
- Exploit scenario
- 1.Attacker observes a pending first deposit of 100,000 USDC in the mempool.
- 2.Attacker front-runs it: deposit(1 wei) mints 1 share.
- 3.Attacker transfers 100,001 USDC directly to the vault, making totalAssets = 100,001 USDC + 1 wei.
- 4.The victim’s deposit computes 100,000e6 × 1 / 100,001e6 = 0 shares and succeeds.
- 5.Attacker redeems 1 share for ~200,001 USDC, netting the victim’s deposit minus gas.
- Impact
Total loss of any deposit made while the attacker controls the inflated share. Losses are bounded only by the size of victim deposits, and the pattern can be repeated whenever the vault returns to an empty state.
- Recommendation
Introduce a virtual share and asset offset in the conversion functions (for example a decimals offset as in OpenZeppelin’s ERC4626 implementation), or mint dead shares to an unrecoverable address at initialisation. In addition, revert any deposit that would mint zero shares and any mint that would require zero assets.
- Fix
- src/QuillonVault.soldiff
function _convertToShares(uint256 assets, Math.Rounding r) internal view returns (uint256) {- uint256 supply = totalSupply();- return supply == 0 ? assets : assets.mulDiv(supply, totalAssets(), r);+ return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, r); } function deposit(uint256 assets, address receiver) public override returns (uint256 shares) { shares = previewDeposit(assets);+ if (shares == 0) revert ZeroShares(); _deposit(msg.sender, receiver, assets, shares); } - Resolution
Fixed with a decimals offset of 6 and a zero-share revert on deposit and mint. Verified in the remediation commit; INV-03 added to the invariant suite.
