Core Contracts

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

Users can still be liquidated even on a healthy health factor resulting in loss of assets.

Vulnerability Details

The interplay between LendingPool's initiateLiquidation, closeLiquidation, and finalizeLiquidation functions
is flawed. When a user health factor (HF) falls below the threshold, initiateLiquidation sets isUnderLiquidation[user] = true and records a liquidationStartTime. Users have a grace period to repay their debt & must manually call closeLiquidation to reset isUnderLiquidation to false.

function closeLiquidation() external nonReentrant whenNotPaused {
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, if they repay but fail to call closeLiquidation before the grace period expires, two severe issues arise,

Issue 1:

If the grace period ends and isUnderLiquidation[user] remains true, the owner can call finalizeLiquidation to liquidate the user. This function only checks isUnderLiquidation and the expired grace period, not the user’s current HF.

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
@> if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
@> if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
// PROCEED TO LIQUIDATE USER!
...
...
}

As a result, a user who repays their debt within grace period and achieves a healthy HF can still be liquidated unfairly if they fail to call the closeLiquidation. In fact, if they fail to call closeLiquidation within grace period, regardless of whether they keep a healthy factor, they can be liquidated by owner or manager.

Isssue 2:

If the owner or manager decids not to liquidate the user after the grace period by checking their health factor, the isUnderLiquidation[user] = true state persists indefinitely since only closeLiquidation can set it to false. So next time their health factor dips, the initiateLiquidation can't be called as it would revert because of isUnderLiquidation[user] = true. No one can re-initiate liquidation if the user’s HF drops below the threshold again later. The issue is the reliance on a single, time-sensitive manual action (closeLiquidation) to reset the liquidation state, combined with initiateLiquidation’s guard against re-initiation and finalizeLiquidation’s lack of an HF check.

Impact

Solvent users can still be liquidated due to oversight, eroding trust and fairness.

Tools Used

Manual Review

Recommendations

Add an HF check in finalizeLiquidation to prevent liquidation of solvent users and prevent users from repaying if they're under liquidation and grace period has ended. Also, make adjustments to the StabilityPool functions that call finalizeLiquidation to account for a return when hf is healthy.

// LendingPool function
function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
+ if (isUnderLiquidation[userAddress]) revert();
...
...
}
// LendingPool function
- function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
+ function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool returns (bool) {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
+ if (healthFactor >= healthFactorLiquidationThreshold) {
+ isUnderLiquidation[userAddress] = false;
+ liquidationStartTime[userAddress] = 0;
+ return false;
+ }
...
...
+ return true;
}
// StabilityPool function
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
...
...
// Approve the LendingPool to transfer the debt amount
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
// Update lending pool state before liquidation
lendingPool.updateState();
// Call finalizeLiquidation on LendingPool
- lendingPool.finalizeLiquidation(userAddress);
+ bool liquidated = lendingPool.finalizeLiquidation(userAddress);
+ if (!liquidated) {
+ crvUSDToken.approve(address(lendingPool), 0);
+ return;
+ }
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
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!