Core Contracts

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

Permanent Lockout Due to Grace Period Reduction

Summary

The protocol allows an owner or governance entity to modify the LendingPool::liquidationGracePeriod using the LendingPool::setParameter function. This creates a vulnerability where a user repaying their debt during the original grace period might be locked out of the protocol if the grace period is shortened before they call closeLiquidation().

Vulnerability Details

  1. Initiate Liquidation:

  • When a user's health factor falls below the healthFactorLiquidationThreshold, initiateLiquidation() is called.

  • The function sets isUnderLiquidation[userAddress] = true and records liquidationStartTime[userAddress] = block.timestamp.

LendingPool::initiateLiquidation:

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);
}

2.Repayment During Grace Period:

  • The user repays their entire debt by calling repay(amount), which reduces user.scaledDebtBalance to zero.

  • However, isUnderLiquidation[userAddress] remains true until closeLiquidation() is called.
    LendingPool::repay:

function repay(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
_repay(amount, msg.sender);
}
function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
UserData storage user = userData[onBehalfOf];
// Update reserve state before repayment
ReserveLibrary.updateReserveState(reserve, rateData);
// Calculate the user's debt (for the onBehalfOf address)
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
// If amount is greater than userDebt, cap it at userDebt
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
// Burn DebtTokens from the user whose debt is being repaid (onBehalfOf)
// is not actualRepayAmount because we want to allow paying extra dust and we will then cap there
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
@> IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
// Transfer reserve assets from the caller (msg.sender) to the reserve
@> IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
@> user.scaledDebtBalance -= amountBurned;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
}

3.Owner or Through Governance Reduces Grace Period:

  • Before the user can call closeLiquidation(), the owner or governance shortens the liquidationGracePeriod using setParameter(OwnerParameter.LiquidationGracePeriod, newValue).

  • This causes block.timestamp > liquidationStartTime[userAddress] + liquidationGracePeriod to evaluate as true, blocking closeLiquidation()
    LendingPool::setParameter:

function setParameter(OwnerParameter param, uint256 newValue) external override onlyOwner {
if (param == OwnerParameter.LiquidationThreshold) {
require(newValue <= 100_00, "Invalid liquidation threshold");
liquidationThreshold = newValue;
emit LiquidationParametersUpdated(liquidationThreshold, healthFactorLiquidationThreshold, liquidationGracePeriod);
}
else if (param == OwnerParameter.HealthFactorLiquidationThreshold) {
healthFactorLiquidationThreshold = newValue;
emit LiquidationParametersUpdated(liquidationThreshold, healthFactorLiquidationThreshold, liquidationGracePeriod);
}
@> else if (param == OwnerParameter.LiquidationGracePeriod) {
@> require(newValue >= 1 days && newValue <= 7 days, "Invalid grace period");
@> liquidationGracePeriod = newValue;
@> emit LiquidationParametersUpdated(liquidationThreshold, healthFactorLiquidationThreshold, liquidationGracePeriod);
@> } ........

LendingPool::closeLiquidation:

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);
}

4.User Is Permanently Blocked:

  • Since isUnderLiquidation[userAddress] is never reset, the user cannot borrow, participate in lending, or perform other protocol functions.

  • The protocol incorrectly treats them as if they still have debt, even though they fully repaid.

Impact

  1. Users are permanently locked out of the protocol despite having repaid their debt in time.

  2. Denial of Service for users because they would not be able to even withdraw his nft back as the parameter is not reset nor would be able to perform any borrorw operations.
    LendingPool::withdrawNFT:

function withdrawNFT(uint256 tokenId) external nonReentrant whenNotPaused {
@> if (isUnderLiquidation[msg.sender]) revert CannotWithdrawUnderLiquidation();
UserData storage user = userData[msg.sender];
if (!user.depositedNFTs[tokenId]) revert NFTNotDeposited();.........

LendingPool::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);......
  1. Funds at Risk: If the user's NFT collateral is still held, they may be unable to reclaim it despite clearing their debt.

Tools Used

Manual

Recommendations

  1. Modify repay() to automatically reset isUnderLiquidation[userAddress] if user.scaledDebtBalance == 0 instead of requiring a separate closeLiquidation() call.

  2. Prevent reducing liquidationGracePeriod for users who are already in liquidation, ensuring they receive the originally set grace period.

Updates

Lead Judging Commences

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

A borrower can LendingPool::repay to avoid liquidation but might not be able to call LendingPool::closeLiquidation successfully due to grace period check, loses both funds and collateral

Support

FAQs

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