Core Contracts

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

User is wrongfully liquidated when health factor is above threshold

Summary

The vulnerability allows the Stability Pool to liquidate users whose positions have become healthy due to collateral value appreciation during the liquidation grace period. This occurs because the protocol does not revalidate the health factor before finalizing the liquidation, leading to unfair liquidations of positions that no longer meet the criteria.

Vulnerability Details

Users who have deposited RAACNft's into the protocol are allowed to borrow assets from the rToken vault via the LendingPool::borrow

/**
* @notice Allows a user to borrow reserve assets using their NFT collateral
* @param amount The amount of reserve assets to borrow
*/
function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();
UserData storage user = userData[msg.sender];
uint256 collateralValue = getUserCollateralValue(msg.sender);
if (collateralValue == 0) revert NoCollateral();
// Update reserve state before borrowing
ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
_ensureLiquidity(amount);
// Fetch user's total debt after borrowing
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Ensure the user has enough collateral to cover the new debt
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
// Update user's scaled debt balance
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Transfer borrowed amount to user
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
// reserve.totalUsage += amount;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
// Rebalance liquidity after borrowing
_rebalanceLiquidity();
emit Borrow(msg.sender, amount);
}

As expected, a health factor is measured which calculates whether the user's position is healthy or unhealthy. Users with unhealthy positions can be liquidated by any user calling LendingPool::initiateLiquidation which starts the liquidation process .

**
* @notice Allows anyone to initiate the liquidation process if a user's health factor is below threshold
* @param userAddress The address of the user to liquidate
*/
function initiateLiquidation(address userAddress) external nonReentrant whenNotPaused {
if (isUnderLiquidation[userAddress]) revert UserAlreadyUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
UserData storage user = userData[userAddress];
uint256 healthFactor = calculateHealthFactor(userAddress);
if (healthFactor >= healthFactorLiquidationThreshold) revert HealthFactorTooLow();
isUnderLiquidation[userAddress] = true;
liquidationStartTime[userAddress] = block.timestamp;
emit LiquidationInitiated(msg.sender, userAddress);
}

Once liquidation has been initiated, the user to be liquidated has a grace period in which they can repay their debts. Once the grace period has passed, liquidatable users can no longer close liquidations and can be liquidated by the stability pool via LendingPool::finalizeliquidation. The issue lies where the price of the RAACNft owned by the user to be liquidated is set to a higher value by RAAC due to any external conditions which increase the value of the user's house.

This price increase can make the user's health factor go above the threshold which no longer qualifies them for liquidation. The problem occurs because if the user doesnt have any funds to pay back to the Lending Pool contract to close the liquidation via LendingPool::closeLiquidation, then the stability pool will be able to liquidate the user when the user's health is above the threshold.

Proof Of Code (POC)

This test was run in protocols-tests.js file in the "StabilityPool" describe block

it("user is wrongfully liquidated when health factor is above threshold", async function () {
//c for testing purposes
await contracts.stabilityPool.connect(user1).deposit(STABILITY_DEPOSIT);
await contracts.crvUSD
.connect(user3)
.approve(contracts.stabilityPool.target, STABILITY_DEPOSIT);
await contracts.crvUSD
.connect(user3)
.transfer(contracts.stabilityPool.target, STABILITY_DEPOSIT); //c this is where the stability pool gets the crvUSD to cover the debt
// Create position to be liquidated
const newTokenId = HOUSE_TOKEN_ID + 2;
await contracts.housePrices.setHousePrice(newTokenId, HOUSE_PRICE);
await contracts.crvUSD
.connect(user2)
.approve(contracts.nft.target, HOUSE_PRICE);
await contracts.nft.connect(user2).mint(newTokenId, HOUSE_PRICE);
await contracts.nft
.connect(user2)
.approve(contracts.lendingPool.target, newTokenId);
await contracts.lendingPool.connect(user2).depositNFT(newTokenId);
await contracts.lendingPool
.connect(user2)
.borrow(ethers.parseEther("90"));
//c at this point, the user's health factor is unhealthy so they can be liquidated
const user2healthfactor =
await contracts.lendingPool.calculateHealthFactor(user2.address);
console.log(`user2healthfactor: ${user2healthfactor}`);
await contracts.lendingPool
.connect(user3)
.initiateLiquidation(user2.address);
await time.increase(24 * 60 * 60);
//c between the grace period, the house price increases to a value that would make the user's health factor healthy again
await contracts.housePrices.setHousePrice(
newTokenId,
ethers.parseEther("150")
);
//c user comes in to perform a routine health factor check after 24 hours and since they are healthy and dont know that liquidation has been initialized on their address, they feel they are safe so they carry on with their activities as usual
const user2healthfactor1 =
await contracts.lendingPool.calculateHealthFactor(user2.address);
console.log(`user2healthfactor1: ${user2healthfactor1}`);
const healthFactorLiquidationThreshold =
await contracts.lendingPool.BASE_HEALTH_FACTOR_LIQUIDATION_THRESHOLD();
assert(user2healthfactor1 > user2healthfactor);
assert(user2healthfactor1 > healthFactorLiquidationThreshold);
//c another 2 days pass which means the grace period is now over
await time.increase(49 * 60 * 60);
//c since initiateliquidation has been called, the stability pool will liquidate user2 successfully which shouldnt happen because the user's health factor was healthy before the grace period ended
await contracts.lendingPool.connect(owner).updateState();
const tx = await contracts.stabilityPool
.connect(owner)
.liquidateBorrower(user2.address);
});

Impact

Unfair Liquidations: Users with healthy positions (post-collateral appreciation) lose their collateral unfairly.

Loss of Trust: Users may lose confidence in the protocol due to unexpected liquidations.

Financial Loss: Borrowers lose collateral for loans they could have otherwise repaid given the updated collateral value.

Tools Used

Manual Review, Hardhat

Recommendations

Ensure collateral prices are refreshed at both initiation and finalization of liquidations to reflect real-time values.

function initiateLiquidation(address borrower) external {
// Use latest collateral price
refreshCollateralPrice(borrower);
// ... existing logic ...
}
function finalizeLiquidation(address borrower) external {
// Re-fetch the latest collateral price
refreshCollateralPrice(borrower);
// ... existing logic ...
}
Updates

Lead Judging Commences

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

LendingPool::finalizeLiquidation() never checks if debt is still unhealthy

Support

FAQs

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

Give us feedback!