Summary
The StabilityPool contract cannot receive or manage ERC721 tokens (NFTs) despite being the designated recipient of liquidated NFTs from the LendingPool. This results in permanently stuck NFTs within the StabilityPool, as there is no functionality to transfer, withdraw, or otherwise handle these assets, leading to protocol inefficiency and potential loss of collateral value.
Vulnerability Details
When a user deposits NFT into LendingPool and borrows crvUSD, if a collateral value drops liquidation is initiated via initiateLiquidation,after the liquidation Grace Period in the stabilityPool manager or owner call liquidateBorrower(userAddress), triggering LendingPool::finalizeLiquidation so all NFTs of the user are transferred to StabilityPool, but the StabilityPool has no functions to transfer, sell, or burn the NFTs. Admins cannot recover NFTs, as the contract lacks ownership or withdrawal logic, NFTs are permanently locked in the StabilityPool, Protocol loses access to collateral.
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
@>> raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
delete user.nftTokenIds;
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
lendingPool.updateState();
@>> lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
Root Cause
-
Missing ERC721 Support: The StabilityPool inherits no ERC721 handling functions (e.g., onERC721Received, safeTransferFrom) and lacks a method to withdraw NFTs.
-
Irreversible Transfers: During liquidation, the LendingPool transfers NFTs to the StabilityPool via raacNFT.transferFrom(address(this), stabilityPool, tokenId). However, the StabilityPool cannot process or forward these NFTs, trapping them indefinitely.
Impact
Collateral NFTs cannot be recovered or reused
Tools Used
Manual Review
Recommendations
Implement ERC721 Receiver:
Add onERC721Received to comply with ERC721 standards:
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
Add NFT Withdrawal Function:
Allow admins to transfer NFTs to a designated treasury:
function withdrawNFT(address nftContract, uint256 tokenId, address recipient) external onlyOwner {
IERC721(nftContract).safeTransferFrom(address(this), recipient, tokenId);
}