Read-only reentrancy lets flash-loan callbacks liquidate vToken collateral at a depressed exchange rate
src/LendingPool.sol:L402–L447 (flashLoan), src/tokens/VToken.sol:L118 (exchangeRateStored), src/liquidation/Liquidator.sol:L86
- Likelihood
- Medium
- Impact
- High
- Weakness
- CWE-1265
- Description
The vToken exchange rate is computed as
(cash + totalBorrows − reserves) / totalSupply, wherecashis the pool's token balance for the market.flashLoan()transfers the requested amount to the receiver and then callsonFlashLoan()before the funds come back. For the duration of the callbackcashis lower by the loan amount while nothing else changes, soexchangeRateStored()reports a value that can be far below the true rate.All state-changing pool functions share a
nonReentrantguard, which correctly prevents a deposit or redemption at the wrong rate. The view function is not guarded, however, and theLiquidatoris a separate contract with its own guard. When an account uses vTokens as collateral,Liquidator.liquidate()values that collateral withexchangeRateStored(), so a liquidation triggered from inside the callback sees the depressed value.This is a read-only reentrancy: the pool never re-enters itself, but another contract reads the pool's state while it is temporarily inconsistent and acts on it.
- Preconditions
- At least one market accepts vTokens as collateral (enabled for vUSDC and vWETH at the reviewed commit).
- Accounts holding vToken collateral are within the reach of the price drop the attacker can induce with available flash-loan liquidity.
- The flash-loaned market holds enough cash for the loan to move the exchange rate materially.
- Exploit scenario
- 1.The attacker identifies accounts whose vUSDC collateral would fall below the liquidation threshold if the vUSDC exchange rate dropped by 15%.
- 2.The attacker flash-borrows most of the USDC market's cash, which lowers
cashand thereforeexchangeRateStored()for vUSDC. - 3.Inside
onFlashLoan(), the attacker callsLiquidator.liquidate()on each target. The liquidator reads the depressed rate, judges the accounts unhealthy and sells their collateral to the attacker at the liquidation discount. - 4.The attacker repays the flash loan plus fee. The exchange rate returns to normal and the attacker now holds collateral bought at a discount from accounts that were never unhealthy.
- Impact
Healthy borrowers lose the liquidation bonus on collateral that should never have been liquidated. Profit scales with the vToken collateral near the threshold and is limited only by flash-loan liquidity. Integrators outside Veyra that price vTokens with the same view function are exposed to the same manipulation.
- Recommendation
Make the pool's accounting consistent during flash loans by adding outstanding flash-loan principal back into the asset total, and make vToken valuation views revert while the pool is entered. The Liquidator should call a pool function that asserts the guard is not entered before valuing any collateral.
- Fix
- src/LendingPool.sol and src/tokens/VToken.sol (remediation commit)diff
function flashLoan(address receiver, address asset, uint256 amount, bytes calldata data) external nonReentrant { Market storage m = markets[asset];+ m.flashOutstanding += amount; IERC20(asset).safeTransfer(receiver, amount); uint256 fee = _flashFee(amount); require(IFlashBorrower(receiver).onFlashLoan(msg.sender, asset, amount, fee, data) == CALLBACK_SUCCESS, "callback"); IERC20(asset).safeTransferFrom(receiver, address(this), amount + fee);+ m.flashOutstanding -= amount; m.reserves += fee; } function totalAssets(address asset) public view returns (uint256) { Market storage m = markets[asset];- return m.cash() + m.totalBorrows - m.reserves;+ return m.cash() + m.flashOutstanding + m.totalBorrows - m.reserves; } + /// Reverts if called while a state-changing pool function is executing.+ function assertNotEntered() public view {+ if (_reentrancyGuardEntered()) revert ReentrantRead();+ } - Resolution
Fixed. Outstanding flash-loan principal is included in
totalAssets,exchangeRateStored()callsassertNotEntered(), and the Liquidator calls it before valuing collateral. A regression test reproduces the original attack and now reverts.
