Summary
Users with unhealthy positions can repay their debt after the liquidation process has been initiated, allowing them to avoid liquidation. This undermines the protocol's liquidation mechanism and poses risks to the stability of the system.
Vulnerability Details
When a user borrows via LendingPool::borrow , they are allowed to repay their debt using LendingPool::_repay. See function below:
* @notice Internal function to repay borrowed reserve assets
* @param amount The amount to repay
* @param onBehalfOf The address of the user whose debt is being repaid. If address(0), msg.sender's debt is repaid.
* @dev This function allows users to repay their own debt or the debt of another user.
* The caller (msg.sender) provides the funds for repayment in both cases.
* If onBehalfOf is set to address(0), the function defaults to repaying the caller's own debt.
*/
function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
UserData storage user = userData[onBehalfOf];
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
}
There is a health factor implemented in RAAC which calculates the health of a user's position and 2 factors are used to determine if a user can be liquidated. The first is the health factor being below a target set by RAAC. If the user's health factor is below target, there is a grace period also configured by RAAC that gives the user an amount of time to get their position back healthy. If the user defaults on this and the grace period passes, LendingPool::initiateLiquidation can be called by any user which begins the liquidation process for the user with the unhealthy position. See function below:
* @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();
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);
}
The issue occurs where any user with any unhealthy position can still repay their debt after LendingPool::initiateLiquidation is called. LendingPool::initiateLiquidation is the initialization process of a liquidation. Once this function is called, the stability pool then calls a function to liquidate the user at any point. At any time between these 2 functions, the user with the unhealthy position could simply repay their debt and stop themselves from being liquidated which shouldn't be the case as once a user's position is to be liquidated, they should not be able to repay.
Proof Of Code (POC)
This test was run in LendingPool.test.js in the "borrow and repay" describe block
it("user can repay their debt after grace period ends and liquidation has been initiated", async function () {
const userCollateral = await lendingPool.getUserCollateralValue(
user1.address
);
console.log("userCollateral", userCollateral);
const borrowAmount = ethers.parseEther("90");
console.log("borrowAmount", borrowAmount);
await lendingPool.connect(user1).borrow(borrowAmount);
const healthFactor = await lendingPool.calculateHealthFactor(
user1.address
);
console.log("healthFactor", healthFactor);
assert(
healthFactor <
(await lendingPool.BASE_HEALTH_FACTOR_LIQUIDATION_THRESHOLD())
);
await time.increase(4 * 24 * 60 * 60);
const reservedata1 = await lendingPool.getAllUserData(user1.address);
const userscaleddebtprerepay = reservedata1.scaledDebtBalance;
console.log(`userscaleddebt`, userscaleddebtprerepay);
await lendingPool.connect(user2).initiateLiquidation(user1.address);
await lendingPool.connect(user1).repay(borrowAmount);
const reservedata = await lendingPool.getAllUserData(user1.address);
const userscaleddebt = reservedata.scaledDebtBalance;
console.log(`userscaleddebt`, userscaleddebt);
assert(userscaleddebt < userscaleddebtprerepay);
});
Impact
Undermined Liquidation Mechanism: Users can bypass liquidation by repaying their debt after the process has been initiated, reducing the effectiveness of the protocol's risk management system.
Increased Protocol Risk: If users are allowed to avoid liquidation, the protocol may accumulate bad debt, leading to financial instability.
Unfair Advantage: Users with unhealthy positions can manipulate the system to avoid penalties, disadvantaging other participants.
Tools Used
Manual Review, Hardhat
Recommendations
To address this issue, the _repay function should be modified to prevent users from repaying their debt once the liquidation process has been initiated. Here’s how this can be implemented:
Updated _repay Function
Add a check to ensure that the user is not under liquidation:
function _repay(uint256 amount, address onBehalfOf) internal {
if (amount == 0) revert InvalidAmount();
if (onBehalfOf == address(0)) revert AddressCannotBeZero();
if (isUnderLiquidation[onBehalfOf]) {
revert CannotRepayUnderLiquidation();
}
UserData storage user = userData[onBehalfOf];
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 userDebt = IDebtToken(reserve.reserveDebtTokenAddress).balanceOf(onBehalfOf);
uint256 userScaledDebt = userDebt.rayDiv(reserve.usageIndex);
uint256 actualRepayAmount = amount > userScaledDebt ? userScaledDebt : amount;
uint256 scaledAmount = actualRepayAmount.rayDiv(reserve.usageIndex);
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(onBehalfOf, amount, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
reserve.totalUsage = newTotalSupply;
user.scaledDebtBalance -= amountBurned;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit Repay(msg.sender, onBehalfOf, actualRepayAmount);
}
Additional Recommendations
Grace Period Enforcement: Ensure that the grace period is strictly enforced and cannot be bypassed by repayments.