Root Cause
ResupplyFi's lending markets accept shares of an ERC-4626 vault (cvcrvUSD — a Curve/Convex yield vault) as collateral. The vault held minimal initial liquidity, making it acutely vulnerable to a first-depositor / donation attack.
In ERC-4626, if totalAssets() is backed by asset.balanceOf(address(this)), an attacker can donate underlying tokens directly to the vault contract (without calling deposit/mint), arbitrarily inflating the reported share price with no corresponding share issuance. ResupplyFi's lending pair then derived an exchange rate from this inflated share price, introducing a precision-loss rounding-to-zero flaw:
// ERC-4626 vault vulnerable to donation attack
function totalAssets() public view override returns (uint256) {
// Includes direct token donations — does NOT use internal balance tracking
return IERC20(asset).balanceOf(address(this)); // <- manipulable via donation
}
// ResupplyPair internal exchange rate calculation
function _getExchangeRate() internal view returns (uint256) {
uint256 totalShares = collateralVault.totalSupply(); // low (minimal depositors)
uint256 totalAssets = collateralVault.totalAssets(); // huge after donation
// PRECISION * totalShares / totalAssets underflows to 0
return PRECISION * totalShares / totalAssets; // <- rounds to zero
}
// Exchange rate ~= 0 means collateral is valued at near-infinity per share
function _maxBorrowable(uint256 collateralShares) internal view returns (uint256) {
uint256 rate = _getExchangeRate(); // 0
// Inverted rate used for LTV: effectively allows infinite borrow
return (rate == 0) ? type(uint256).max : PRECISION * collateralShares / rate;
}
Attack Steps
| Step | Action | Detail |
|---|---|---|
| 1 | Flash loan | Borrowed $4,000 — an extremely low capital requirement |
| 2 | Donate to cvcrvUSD | Transferred crvUSD directly to the vault contract without minting shares |
| 3 | Share price inflation | cvcrvUSD reported share price inflated by many orders of magnitude |
| 4 | Exchange rate rounds to zero | ResupplyPair _getExchangeRate() computed effectively zero (PRECISION underflow) |
| 5 | Uncollateralized borrow | Deposited trivial collateral shares; borrowed $10M reUSD |
| 6 | Swap and split | Swapped reUSD to ETH; split across two attacker-controlled addresses |
| 7 | Repay flash loan | Returned the $4,000 + minimal fees |
| 8 | Launder | Ongoing ETH deposits into Tornado Cash as of July 8-9 |
Impact
- Loss: ~$9.6M from ResupplyFi's wstUSR market on Ethereum
- Attack capital: Only ~$4,000 flash loan — a 2,400x return on attack cost
- Funds: Split across two attacker wallets; partial laundering via Tornado Cash ongoing as of July 8-9
- Protocol response: Affected markets paused immediately
Lessons for Auditors
- Use internal balance tracking in ERC-4626
totalAssets(): Never useasset.balanceOf(address(this))directly. Track_totalDepositedinternally, incrementing only ondeposit/mintand decrementing onwithdraw/redeem. OpenZeppelin's reference implementation uses virtual shares/assets for this purpose. - Virtual shares defense: Seed the vault with a virtual deposit at initialization (
_mint(address(0x000...dead), MIN_SHARES)paired with a real deposit) to make the inflation attack cost prohibitive. - Precision loss in derived exchange rates: When computing ratios used for borrowing power, verify that the calculation cannot round to zero. Use
Math.mulDivwith explicit rounding direction guards and add minimum value assertions. - Minimum liquidity requirements: Markets launched with near-zero initial liquidity are disproportionately vulnerable. Require a protocol-owned minimum seed liquidity before opening to general borrowers.
- Audit ERC-4626 integrations holistically: When a lending protocol uses ERC-4626 vault shares as collateral, audit not just the vault's share math but every derived quantity (exchange rates, LTV calculations, liquidation thresholds) for precision loss under adversarial share prices.