Description
The lending protocol's getNFTPrice function fails to validate the staleness of price data retrieved from the oracle. While the oracle returns both a price and timestamp, the function only checks if the price is non-zero, ignoring the timestamp completely. This oversight is particularly critical as this function is used in core protocol functions that determine collateral valuations and withdrawal permissions, potentially leading to significant financial risks.
Affected code
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L588-L600
function getNFTPrice(uint256 tokenId) public view returns (uint256) {
(uint256 price, uint256 lastUpdateTimestamp) = priceOracle.getLatestPrice(tokenId);
if (price == 0) revert InvalidNFTPrice();
return price;
}
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L560-L576
function getUserCollateralValue(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
uint256 totalValue = 0;
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
uint256 price = getNFTPrice(tokenId);
totalValue += price;
}
return totalValue;
}
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L285-L320
function withdrawNFT(uint256 tokenId) external nonReentrant whenNotPaused {
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
uint256 collateralValue = getUserCollateralValue(msg.sender);
uint256 nftValue = getNFTPrice(tokenId);
if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
}
Vulnerability details
The vulnerability becomes especially dangerous in two critical protocol functions that rely on getNFTPrice:
-
Collateral Valuation (getUserCollateralValue)
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 price = getNFTPrice(tokenId);
totalValue += price;
}
Impact:
Multiple stale prices could compound into severely misreported total collateral value
If a user has 5 NFTs and each price is stale by -20%, their $100k position could actually be worth $80k
This affects all protocol functions that rely on collateral valuations
-
NFT Withdrawals (withdrawNFT)
collateralValue = getUserCollateralValue(msg.sender);
nftValue = getNFTPrice(tokenId);
if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold))
Real-world scenario:
Actual market conditions:
- User has 2 NFTs: NFT A (worth 70 ETH) and NFT B (worth 30 ETH)
- Total collateral = 100 ETH
- User debt = 60 ETH
- Liquidation threshold = 80%
- Minimum required collateral = 75 ETH
With stale prices (20% higher):
- System sees: NFT A (84 ETH) and NFT B (36 ETH)
- System calculates total collateral = 120 ETH
- System allows withdrawal of NFT A thinking 36 ETH collateral remains
- Actually only 30 ETH collateral remains, position should be liquidated
Tools Used
Manual Review
Recommended Mitigation Steps
Add staleness validation to getNFTPrice:
uint256 constant MAX_PRICE_AGE = 1 hours;
function getNFTPrice(uint256 tokenId) public view returns (uint256) {
(uint256 price, uint256 lastUpdateTimestamp) = priceOracle.getLatestPrice(tokenId);
if (price == 0) revert InvalidNFTPrice();
if (block.timestamp - lastUpdateTimestamp > MAX_PRICE_AGE) {
revert StalePriceNotAllowed();
}
return price;
}