Core Contracts

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

Missing Debt Repayment `repay` in closeLiquidation Allows Liquidation Bypass

Summary

The closeLiquidation function in the LendingPool contract is designed to allow users to close their liquidation status by repaying their outstanding debt during the grace period. However, due to a bug in the implementation, the function does not call any debt repayment logic. As a result, a user under liquidation can simply call closeLiquidation without actually repaying any debt, bypassing the liquidation process. This not only undermines the intended risk mitigation mechanics but may also leave the user's debt unresolved while their liquidation flag remains misleading.

Vulnerability Details

How It Begins

The intended behavior of closeLiquidation is to allow a user under liquidation to clear their debt and thereby exit the liquidation process before the grace period expires. The function should verify that the user's remaining debt is minimal (below a defined DUST_THRESHOLD) before clearing the liquidation status.

Current Implementation:

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);
// @info: it's a bug, to see the test working please comment below loc out.
// @info: already mentioned in an another finding
// even though if we don't fix this bug then
// the current repay bug will be activated as soon as userDebt go below DUST_THRESHOLD
if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
// @info: forgot to call the repay function
emit LiquidationClosed(userAddress);
}

The function checks if the user’s debt is below the DUST_THRESHOLD, and then it resets the liquidation flags without processing any repayment of debt. This omission allows a user to exit liquidation even if a significant debt remains, effectively bypassing the proper liquidation mechanics.

Proof of Concept

Scenario Example

  1. Setup:

    • Alice deposits reserve assets into the pool and mints an NFT as collateral.

    • She then borrows an amount (e.g., 900e18) such that her health factor falls below the liquidation threshold.

  2. Liquidation Initiation:

    • Bob initiates Alice's liquidation by calling initiateLiquidation(ALICE), flagging her as under liquidation and starting the grace period.

  3. Bypassing Repayment:

    • Despite still owing a significant debt, Alice calls closeLiquidation after the grace period (with any debt below DUST_THRESHOLD, or by commenting out the revert check in tests) and the function resets her liquidation state without performing any debt repayment.

  4. Result:

    • The test verifies that after closing liquidation, Alice’s debt remains unchanged. This means the function allows her to bypass the proper repayment process, leaving the underlying debt unresolved.

Test Suite Code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../src/core/pools/StabilityPool/StabilityPool.sol";
import {LendingPool} from "../src/core/pools/LendingPool/LendingPool.sol";
import {DEToken} from "../src/core/tokens/DEToken.sol";
import {RToken} from "../src/core/tokens/RToken.sol";
import {DebtToken} from "../src/core/tokens/DebtToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {RAACMinter} from "../src/core/minters/RAACMinter/RAACMinter.sol";
import {RAACHousePrices} from "../src/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../src/core/tokens/RAACNFT.sol";
import {CurveCrvUSDVaultMock} from "./mocks/CurveCrvUSDVaultMock.m.sol";
import {crvUSDToken} from "../src/mocks/core/tokens/crvUSDToken.sol";
import {ERC20Mock} from "../src/mocks/core/tokens/ERC20Mock.sol";
import {ILendingPool} from "../src/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {PercentageMath} from "../src/libraries/math/PercentageMath.sol";
import {WadRayMath} from "../src/libraries/math/WadRayMath.sol";
contract PoolsTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
DEToken deToken;
RToken rToken;
DebtToken debtToken;
RAACToken raacToken;
RAACMinter raacMinter;
RAACHousePrices raacHousePrices;
RAACNFT raacNft;
CurveCrvUSDVaultMock curveCrvUsdVault;
crvUSDToken crvUsdToken;
ERC20Mock erc20Mock;
// owners
address STABILITY_POOL_OWNER = makeAddr("STABILITY_POOL_OWNER");
address LENDING_POOL_OWNER = makeAddr("LENDING_POOL_OWNER");
address DETOKEN_OWNER = makeAddr("DETOKEN_OWNER");
address RTOKEN_OWNER = makeAddr("RTOKEN_OWNER");
address DEBT_TOKEN_OWNER = makeAddr("DEBT_TOKEN_OWNER");
address RAAC_TOKEN_OWNER = makeAddr("RAAC_TOKEN_OWNER");
address RAAC_MINTER_DUMMY = makeAddr("RAAC_MINTER_DUMMY");
address RAAC_HOUSE_PRICES_OWNER = makeAddr("RAAC_HOUSE_PRICES_OWNER");
address RAAC_HOUSE_PRICES_ORACLE = makeAddr("RAAC_HOUSE_PRICES_ORACLE");
address CRV_USD_TOKEN_OWENR = makeAddr("CRV_USD_TOKEN_OWENR");
address CURVE_CRV_USD_VAULT_OWENR = makeAddr("CURVE_CRV_USD_VAULT_OWENR");
address ERC20_MOCK_TOKEN_OWNER = makeAddr("ERC20_MOCK_TOKEN_OWNER");
address RAAC_NFT_TOKEN_OWNER = makeAddr("RAAC_NFT_TOKEN_OWNER");
address RAAC_MINTER_OWNER = makeAddr("RAAC_MINTER_OWNER");
address RAAC_OWNER = makeAddr("RAAC_OWNER");
// users
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
// managers
address MANAGER_1 = makeAddr("MANAGER_1");
address MANAGER_2 = makeAddr("MANAGER_2");
address MANAGER_3 = makeAddr("MANAGER_3");
address MANAGER_4 = makeAddr("MANAGER_4");
address MANAGER_5 = makeAddr("MANAGER_5");
address MANAGER_6 = makeAddr("MANAGER_6");
address MANAGER_7 = makeAddr("MANAGER_7");
address MANAGER_8 = makeAddr("MANAGER_8");
address MANAGER_9 = makeAddr("MANAGER_9");
address MANAGER_10 = makeAddr("MANAGER_10");
uint256 initialPrimeRate = 5e27;
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
function setUp() public {
vm.warp(block.timestamp + 1 days + 1);
vm.startPrank(CRV_USD_TOKEN_OWENR);
crvUsdToken = new crvUSDToken(CRV_USD_TOKEN_OWENR);
vm.stopPrank();
vm.startPrank(CURVE_CRV_USD_VAULT_OWENR);
curveCrvUsdVault = new CurveCrvUSDVaultMock(address(crvUsdToken), CURVE_CRV_USD_VAULT_OWENR);
vm.stopPrank();
vm.startPrank(RTOKEN_OWNER);
rToken = new RToken("R_Token_V1", "RTKNV1", RTOKEN_OWNER, address(crvUsdToken));
vm.stopPrank();
vm.startPrank(DETOKEN_OWNER);
deToken = new DEToken("DE_Token_V1", "DETKNV1", DETOKEN_OWNER, address(rToken));
vm.stopPrank();
vm.startPrank(DEBT_TOKEN_OWNER);
debtToken = new DebtToken("DEBT_TOKEN_V1", "DEBTKNV1", DEBT_TOKEN_OWNER);
vm.stopPrank();
vm.startPrank(ERC20_MOCK_TOKEN_OWNER);
erc20Mock = new ERC20Mock("ERC20_MOCK_TOKEN", "ERC20MTKN");
vm.stopPrank();
vm.startPrank(RAAC_HOUSE_PRICES_OWNER);
raacHousePrices = new RAACHousePrices(RAAC_HOUSE_PRICES_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_NFT_TOKEN_OWNER);
raacNft = new RAACNFT(address(erc20Mock), address(raacHousePrices), RAAC_NFT_TOKEN_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_OWNER);
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.stopPrank();
vm.startPrank(LENDING_POOL_OWNER);
lendingPool = new LendingPool(
address(crvUsdToken),
address(rToken),
address(debtToken),
address(raacNft),
address(raacHousePrices),
initialPrimeRate
);
vm.stopPrank();
vm.startPrank(STABILITY_POOL_OWNER);
stabilityPool = new StabilityPool(STABILITY_POOL_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER_OWNER);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), RAAC_MINTER_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(address(raacMinter));
vm.stopPrank();
vm.startPrank(LENDING_POOL_OWNER);
lendingPool.setCurveVault(address(curveCrvUsdVault));
vm.stopPrank();
vm.startPrank(RTOKEN_OWNER);
rToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(DEBT_TOKEN_OWNER);
debtToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(STABILITY_POOL_OWNER);
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvUsdToken),
address(lendingPool)
);
vm.stopPrank();
crvUsdToken.mint(address(lendingPool), 1000_000_000e18);
}
function testUsersCanBypassLiquidationWithoutRepayingDebts() public {
uint256 tokenId = 1;
uint256 housePrice = 1000e18;
vm.startPrank(RAAC_HOUSE_PRICES_OWNER);
// set oracle
raacHousePrices.setOracle(RAAC_HOUSE_PRICES_ORACLE);
vm.stopPrank();
vm.startPrank(RAAC_HOUSE_PRICES_ORACLE);
// house price in USD
raacHousePrices.setHousePrice(tokenId, housePrice);
vm.stopPrank();
// ensure alice has sufficient balance
crvUsdToken.mint(ALICE, 1000e18);
vm.startPrank(ALICE);
crvUsdToken.approve(address(lendingPool), 1000e18);
// deposit 1000e18 into lending pool to have some liquidity
lendingPool.deposit(1000e18);
vm.stopPrank();
// alice pays in erc20 to mint nft
erc20Mock.mint(ALICE, housePrice);
vm.startPrank(ALICE);
erc20Mock.approve(address(raacNft), housePrice);
// mint 1 nft with tokenId 1
raacNft.mint(tokenId, housePrice);
vm.stopPrank();
vm.startPrank(ALICE);
raacNft.approve(address(lendingPool), tokenId);
// deposit nft into lending pool
lendingPool.depositNFT(tokenId);
vm.stopPrank();
(, uint256 scaledDebtBalance,, bool isUnderLiquidation,,,,) = lendingPool.getAllUserData(ALICE);
console.log("After depositNFT... ");
console.log("scaledDebtBalance: ", scaledDebtBalance);
console.log("isUnderLiquidation: ", isUnderLiquidation);
uint256 borrowAmount = 900e18;
vm.startPrank(ALICE);
// borrow 900e18
lendingPool.borrow(borrowAmount);
vm.stopPrank();
(, scaledDebtBalance,, isUnderLiquidation,,,,) = lendingPool.getAllUserData(ALICE);
console.log("After borrow... ");
console.log("scaledDebtBalance: ", scaledDebtBalance);
console.log("isUnderLiquidation: ", isUnderLiquidation);
uint256 aliceHealthFactor = lendingPool.calculateHealthFactor(ALICE);
assert(aliceHealthFactor < lendingPool.BASE_HEALTH_FACTOR_LIQUIDATION_THRESHOLD());
// bob initiates alice's liquidation
vm.startPrank(BOB);
lendingPool.initiateLiquidation(ALICE);
vm.stopPrank();
(, scaledDebtBalance,, isUnderLiquidation,,,,) = lendingPool.getAllUserData(ALICE);
uint256 aliceDebtBeforeCloseLiquidation = scaledDebtBalance;
console.log("After initiate liquidation... ");
console.log("scaledDebtBalance: ", scaledDebtBalance);
console.log("isUnderLiquidation: ", isUnderLiquidation);
// alice closes her liquidation without repaying her debts
// in the closeLiquidation, there's an another bug which causes a permanent dos
// for this simulation, that bug was commented out.
// another bug: if (userDebt > DUST_THRESHOLD) revert DebtNotZero();
vm.startPrank(ALICE);
lendingPool.closeLiquidation();
vm.stopPrank();
(, scaledDebtBalance,, isUnderLiquidation,,,,) = lendingPool.getAllUserData(ALICE);
uint256 aliceDebtAfterCloseLiquidation = scaledDebtBalance;
console.log("After close liquidation... ");
console.log("scaledDebtBalance: ", scaledDebtBalance);
console.log("isUnderLiquidation: ", isUnderLiquidation);
assertEq(aliceDebtAfterCloseLiquidation, aliceDebtBeforeCloseLiquidation);
}
}

How to Run the Test

  1. Create a Foundry Project:
    Open your terminal and run:

    forge init my-foundry-project
  2. Place Contract Files:
    Place all relevant contract files (e.g., LendingPool.sol, RAACNFT.sol, RAACHousePrices.sol, etc.) in the src directory of your project.

  3. Create Test Directory:
    Create a directory named test adjacent to the src directory, and add the test file (e.g., PoolsTest.t.sol) containing the above test suite code.

  4. Run the Test:
    Execute the following command in your terminal:

    forge test --mt testUsersCanBypassLiquidationWithoutRepayingDebts -vv

Impact

  • Liquidation Bypass:
    Users can exit the liquidation process without repaying any debt, leaving their outstanding obligations unresolved.

  • Inconsistent Risk Management:
    This bypass undermines the risk mitigation process designed to protect the protocol against defaults, potentially increasing systemic risk.

  • User Blacklisting and System Instability:
    Since the liquidation process is not properly completed, users may be left in a permanently flagged state, leading to further complications in collateral management and potential blacklisting.

Tools Used

  • Manual Review

  • Foundry (Forge)

Recommendations

To remediate this vulnerability, modify the closeLiquidation function to include the debt repayment logic. The function should:

  1. Ensure that the user’s outstanding debt is repaid (or at least, a repayment function is called) before clearing the liquidation state.

  2. Update the user’s debt balance accordingly, so that the liquidation process accurately reflects the repayment.

Recommended Diff

@@ In LendingPool.sol, function closeLiquidation:
- // @info: forgot to call the repay function
- emit LiquidationClosed(userAddress);
+ // Repay the remaining debt (if any) to close liquidation
+ _repay(userDebt, userAddress);
+ emit LiquidationClosed(userAddress);

By integrating the repayment step, the function will enforce that the liquidation can only be closed once the debt is appropriately addressed, thereby preventing users from bypassing the liquidation process.

After applying these modifications, rerun the test suite to confirm that the user's debt is properly reduced during closeLiquidation and that liquidation cannot be bypassed without actual debt repayment.

Updates

Lead Judging Commences

inallhonesty Lead Judge
8 months ago
inallhonesty Lead Judge 7 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Appeal created

theirrationalone Submitter
7 months ago
theirrationalone Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 6 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.

Give us feedback!