Core Contracts

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

Users can be liquidated despite having sufficient health factor if they call `closeLiquidation()` after grace period

Summary

The closeLiquidation() function reverts if called after the grace period, even if the user has repaid their debt or added sufficient collateral during the grace period. This allows the stability pool to liquidate healthy positions through finalizeLiquidation().

Vulnerability Details

In LendingPool::closeLiquidation() function checks if the grace period has expired before allowing users to close their liquidation:

function closeLiquidation() external nonReentrant whenNotPaused {
address userAddress = msg.sender;
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
@> if (block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
emit LiquidationClosed(userAddress);
}

However, this check is purely time-based and doesn't consider if the user has:

  1. Repaid their debt through repay()

  2. Added more collateral by depositing NFTs

  3. Improved their health factor above the liquidation threshold

Even if a user takes any of these actions during the grace period to make their position healthy, they cannot close the liquidation if they call closeLiquidation() after the grace period expires. This allows the stability pool to call finalizeLiquidation() and seize the user's collateral despite the position being properly collateralized.

Impact

Users who successfully restore their position's health during the grace period but fail to call closeLiquidation() in time will:

  1. Lose all their NFT collateral to the stability pool

  2. Lose the repaid debt

  3. Suffer significant financial losses even though their position was healthy

Tools Used

Manual review

Proof of Concept

Add the following test case to the test/unit/core/pools/LendingPool/LendingPool.test.js file in the Liquidation section:

it("allows user to repay on time, but can't close liquidation after grace period, so he will be liquidated", async function () {
// Set Stability Pool address (using owner for this test)
await lendingPool.connect(owner).setStabilityPool(owner.address);
// Decrease house price and initiate liquidation
await raacHousePrices.setHousePrice(1, ethers.parseEther("90"));
// Initiate liquidation
await lendingPool.connect(user2).initiateLiquidation(user1.address);
// User1 is able to repay debt
const userDebt = await lendingPool.getUserDebt(user1.address);
const debtTokenBalance = await debtToken.balanceOf(user1.address);
expect(debtTokenBalance).to.be.equal(userDebt);
const userBalanceBeforeRepay = await crvusd.balanceOf(user1.address);
const repayAmount = debtTokenBalance + ethers.parseEther("1");
await lendingPool.connect(user1).repay(repayAmount);
// User1's debt is repaid
const userBalanceAfterRepay = await crvusd.balanceOf(user1.address);
const userDebtAfterRepay = await lendingPool.getUserDebt(user1.address);
const debtTokenBalanceAfterRepay = await debtToken.balanceOf(user1.address);
expect(userBalanceAfterRepay).to.be.lte(userBalanceBeforeRepay - debtTokenBalance);
expect(userBalanceAfterRepay).to.be.gte(userBalanceBeforeRepay - repayAmount);
expect(userDebtAfterRepay).to.be.equal(0);
expect(debtTokenBalanceAfterRepay).to.be.equal(0);
// User1 is still under liquidation
expect(await lendingPool.isUnderLiquidation(user1.address)).to.be.true;
// Advance time beyond grace period (72 hours)
await ethers.provider.send("evm_increaseTime", [72 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
// User1 is not able to close liquidation after grace period even if he has repaid his debt on time
await expect(lendingPool.connect(user1).closeLiquidation())
.to.be.revertedWithCustomError(lendingPool, "GracePeriodExpired");
// Stability Pool closes liquidation
await expect(lendingPool.connect(owner).finalizeLiquidation(user1.address))
.to.emit(lendingPool, "LiquidationFinalized")
// Verify that the user is no longer under liquidation
expect(await lendingPool.isUnderLiquidation(user1.address)).to.be.false;
// Verify that the NFT has been transferred to the Stability Pool
expect(await raacNFT.ownerOf(1)).to.equal(owner.address);
});

Recommendations

The closeLiquidation() function should check the user's current health factor instead of only the grace period:

function closeLiquidation() external nonReentrant whenNotPaused {
address userAddress = msg.sender;
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
+ // Allow closing if position is healthy, regardless of time
- if (block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod) {
+ if (block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod &&
+ calculateHealthFactor(userAddress) < healthFactorLiquidationThreshold) {
+ revert GracePeriodExpired();
+ }
if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
emit LiquidationClosed(userAddress);
}
Updates

Lead Judging Commences

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