Summary
A critical asset custody flaw exists in the LendingPool's vault withdrawal mechanism (LendingPool._withdrawFromVault
) where funds retrieved from Curve vaults are not properly routed to the RToken contract. This breaks the protocol's core liquidity management by creating a mismatch between internal accounting records and actual asset availability, preventing users from withdrawing their deposited funds despite correct on-paper balances. The issue affects all vault withdrawal operations and propagates to dependent systems like borrowing flow.
Vulnerability Details
The vulnerability stems from incorrect fund flow handling when withdrawing liquidity from the Curve vault in the LendingPool contract. When executing LendingPool._withdrawFromVault
(LendingPool.sol#L809-L812), the protocol successfully retrieves reserve assets from the Curve vault but fails to route them to their designated holding contract (RToken).
Key issue points:
Withdrawn assets remain stranded in LendingPool
RToken contract maintains separate accounting for user-withdrawable assets
ReserveLibrary withdrawal process assumes RToken holds all available liquidity
contract LendingPool is ILendingPool, Ownable, ReentrancyGuard, ERC721Holder, Pausable {
function _withdrawFromVault(uint256 amount) internal {
@> curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
totalVaultDeposits -= amount;
}
function _rebalanceLiquidity() internal {
if (address(curveVault) == address(0)) {
return;
}
uint256 totalDeposits = reserve.totalLiquidity;
uint256 desiredBuffer = totalDeposits.percentMul(liquidityBufferRatio);
@> uint256 currentBuffer = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (currentBuffer > desiredBuffer) {
uint256 excess = currentBuffer - desiredBuffer;
_depositIntoVault(excess);
} else if (currentBuffer < desiredBuffer) {
uint256 shortage = desiredBuffer - currentBuffer;
@> _withdrawFromVault(shortage);
}
emit LiquidityRebalanced(currentBuffer, totalVaultDeposits);
}
function _ensureLiquidity(uint256 amount) internal {
if (address(curveVault) == address(0)) {
return;
}
@> uint256 availableLiquidity = IERC20(reserve.reserveAssetAddress).balanceOf(reserve.reserveRTokenAddress);
if (availableLiquidity < amount) {
uint256 requiredAmount = amount - availableLiquidity;
@> _withdrawFromVault(requiredAmount);
}
}
}
library ReserveLibrary {
function withdraw(
ReserveData storage reserve,
ReserveRateData storage rateData,
uint256 amount,
address recipient
) internal returns (uint256 amountWithdrawn, uint256 amountScaled, uint256 amountUnderlying) {
if (amount < 1) revert InvalidAmount();
updateReserveInterests(reserve, rateData);
@> (uint256 burnedScaledAmount, uint256 newTotalSupply, uint256 amountUnderlying) = IRToken(reserve.reserveRTokenAddress).burn(
recipient,
recipient,
amount,
reserve.liquidityIndex
);
amountWithdrawn = burnedScaledAmount;
updateInterestRatesAndLiquidity(reserve, rateData, 0, amountUnderlying);
emit Withdraw(recipient, amountUnderlying, burnedScaledAmount);
return (amountUnderlying, burnedScaledAmount, amountUnderlying);
}
}
contract RToken is ERC20, ERC20Permit, IRToken, Ownable {
function burn(
address from,
address receiverOfUnderlying,
uint256 amount,
uint256 index
) external override onlyReservePool returns (uint256, uint256, uint256) {
_burn(from, amount.toUint128());
if (receiverOfUnderlying != address(this)) {
@> IERC20(_assetAddress).safeTransfer(receiverOfUnderlying, amount);
}
}
}
This creates a critical mismatch where:
The protocol's internal accounting (totalVaultDeposits
) shows reduced vault exposure
The actual withdrawable assets in RToken don't receive the retrieved funds
Subsequent withdrawal attempts from users will fail due to insufficient RToken balances
The root cause is the missing asset transfer step between LendingPool and RToken after vault withdrawals, breaking the protocol's asset custody chain.
Impact
This vulnerability creates severe protocol-level consequences:
-
Failed User Withdrawals
Users cannot redeem their RToken holdings as the RToken contract lacks sufficient underlying assets, despite the protocol's internal accounting showing available liquidity.
-
Protocol Insolvency Risk
Creates a dangerous imbalance where recorded assets exceed actual holdings, potentially making the protocol technically insolvent.
The vulnerability fundamentally undermines the protocol's ability to honor user withdrawals while maintaining proper accounting, representing a critical failure in core protocol functionality.
Tools Used
Manual Review
Recommendations
Modify LendingPool._withdrawFromVault
to route withdrawn assets to RToken:
function _withdrawFromVault(uint256 amount) internal {
curveVault.withdraw(amount, address(this), msg.sender, 0, new address[](0));
+ IERC20(reserve.reserveAssetAddress).safeTransfer(reserve.reserveRTokenAddress, amount);
totalVaultDeposits -= amount;
}