Core Contracts

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

Denial of Service in finalize liquidation Bypassing Liquidation Mechanics

Summary

The LendingPool contract provides functions for repaying debt—both for oneself via repay and on behalf of another user via repayOnBehalf. According to the NatSpec documentation, if the onBehalfOf parameter is set to address(0), the repayment should default to the caller's own debt. More importantly, the protocol’s design intends that only healthy users (i.e., users not under liquidation) can actively repay their debts. However, the current implementation does not restrict users who are under liquidation from repaying their debt. This loophole enables a scenario where a user, even after being flagged as under liquidation, can repay their debt, thereby reducing the debt balance to zero. Consequently, when the Stability Pool later attempts to finalize liquidation, it encounters zero debt—causing the liquidation process to revert. This bug undermines the intended liquidation mechanics and effectively locks the user in an under-liquidation state, potentially blacklisting them and disrupting the system's debt resolution process. Lastly, an under-liquidation user can repay their debt using the repay function and also can repay on behalf of another under-liquidation user using the repayOnBehalf function, bypassing the intended liquidation mechanics.

Vulnerability Details

How It Begins

  1. Repayment Functionality:

    • The repay function allows any user to repay their own debt by calling:

      function repay(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
      // @info: currently anyone can repay their debts,
      // underLiquidation users also
      // but only healthy users should be able to repay their debts
      _repay(amount, msg.sender);
      }
    • Similarly, repayOnBehalf enables repayment on behalf of another user:

      function repayOnBehalf(uint256 amount, address onBehalfOf)
      external
      nonReentrant
      whenNotPaused
      onlyValidAmount(amount)
      {
      if (!canPaybackDebt) revert PaybackDebtDisabled();
      if (onBehalfOf == address(0)) revert AddressCannotBeZero();
      // @info: anyone can repay others debts even
      // after grace period.
      // More, an under-liquidation user can repay on behalf of
      // another under-liquidation user
      _repay(amount, onBehalfOf);
      }
  2. Internal Repayment Logic:

    • The _repay function does not include any checks to prevent repayment by users flagged as under liquidation. Even though the NatSpec specifies that if onBehalfOf is address(0) the function should default to repaying the caller's debt, the implementation reverts if a zero address is provided:

      function _repay(uint256 amount, address onBehalfOf) internal {
      if (amount == 0) revert InvalidAmount();
      if (onBehalfOf == address(0)) revert AddressCannotBeZero();
      // ... rest of the logic
      }
    • This oversight permits a user, even when under liquidation, to call repay (or repayOnBehalf for themselves) and reduce their debt to zero.

  3. Liquidation Mechanics Conflict:

    • When a user (e.g., Alice) falls below the required health factor, another party (e.g., Bob) may initiate liquidation by calling initiateLiquidation, which sets isUnderLiquidation[Alice] to true and starts a grace period.

    • During the grace period, Alice has the option to close liquidation via closeLiquidation if her debt is near zero. However, if the grace period expires and she has not closed liquidation, the Stability Pool is expected to finalize the liquidation using finalizeLiquidation.

    • If Alice, despite being under liquidation, repays her debt using the repay function, her debt balance becomes zero. Consequently, when the Stability Pool later calls finalizeLiquidation, the calculation of userDebt results in zero, causing the debt-burning mechanism to revert.

    • And, anyone else no matter if they are under liquidation or not can repay on behalf of Alice using repayOnBehalf function. This will also reduce Alice's debt to zero.

Proof of Concept

Scenario Example

  • Setup:

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

    • She borrows 900e18 from the pool, thereby increasing her debt and reducing her health factor below the liquidation threshold.

  • Liquidation Initiation:

    • Bob detects that Alice’s health factor is below the threshold and calls initiateLiquidation(Alice). Now, isUnderLiquidation[Alice] is set to true and the liquidation grace period begins.

  • Repayment by an Under-Liquidation User:

    • Instead of allowing only the closeLiquidation mechanism to resolve her position, Alice (or even someone (under liquidated or not no matter) on her behalf) repays her debt via the repay or repayOnBehalf function.

    • This repayment reduces her debt to zero.

  • Liquidation Finalization Failure:

    • Later, when the Stability Pool attempts to finalize liquidation by calling finalizeLiquidation(Alice), the function calculates userDebt as zero. This unexpected state causes the debt-burning process to fail and the transaction to revert.

  • Result:

    • Alice remains permanently under liquidation (with isUnderLiquidation[Alice] still set to true), effectively blacklisting her and preventing further collateral withdrawals or new borrowings.

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 testUsersCanRepayDebtsEvenAfterGracePeriod() 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,,,,,,) = lendingPool.getAllUserData(ALICE);
console.log("After depositNFT... ");
console.log("scaledDebtBalance: ", scaledDebtBalance);
uint256 borrowAmount = 900e18;
vm.startPrank(ALICE);
// borrow 900e18
lendingPool.borrow(borrowAmount);
vm.stopPrank();
(, scaledDebtBalance,,,,,,) = lendingPool.getAllUserData(ALICE);
console.log("After borrow... ");
console.log("scaledDebtBalance: ", scaledDebtBalance);
uint256 aliceHealthFactor = lendingPool.calculateHealthFactor(ALICE);
// 888888888888888888 or 0.8, < 1
console.log("Alice's health factor: ", aliceHealthFactor);
vm.startPrank(BOB);
lendingPool.initiateLiquidation(ALICE);
vm.stopPrank();
assertEq(lendingPool.isUnderLiquidation(ALICE), true);
assertEq(lendingPool.liquidationStartTime(ALICE), block.timestamp);
vm.warp(block.timestamp + 3 days + 1);
// Alice hasn't closed her liquidation
// before this happens
// lendingPool.finalizeLiquidation(ALICE);
// alice realizes her debts and paybacks her debts using repay function
// because now she can't enter into closeLiquidation function
console.log("Alice debt before repay: ", lendingPool.getUserDebt(ALICE));
vm.startPrank(ALICE);
crvUsdToken.mint(ALICE, 100e18);
crvUsdToken.approve(address(lendingPool), 1000e18);
lendingPool.repay(lendingPool.getUserDebt(ALICE));
lendingPool.repay(lendingPool.getUserDebt(ALICE));
vm.stopPrank();
console.log("Alice debt after repay: ", lendingPool.getUserDebt(ALICE));
vm.startPrank(STABILITY_POOL_OWNER);
vm.expectRevert(bytes4(keccak256("InvalidAmount()")));
stabilityPool.liquidateBorrower(ALICE);
vm.stopPrank();
vm.startPrank(ALICE);
vm.expectRevert(bytes4(keccak256("CannotWithdrawUnderLiquidation()")));
lendingPool.withdrawNFT(tokenId);
vm.expectRevert(bytes4(keccak256("CannotBorrowUnderLiquidation()")));
lendingPool.borrow(borrowAmount);
vm.expectRevert(bytes4(keccak256("GracePeriodExpired()")));
lendingPool.closeLiquidation();
vm.stopPrank();
vm.startPrank(BOB);
vm.expectRevert(bytes4(keccak256("UserAlreadyUnderLiquidation()")));
lendingPool.initiateLiquidation(ALICE);
vm.stopPrank();
}
}

How to Run the Test

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

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

  2. Create Test Directory:
    Create a directory named test adjacent to the src directory, and add the test file (for example, PoolsTest.t.sol) that contains the test suite code.

  3. Run the Test:
    In your terminal, execute:

forge test --mt testUsersCanRepayDebtsEvenAfterGracePeriod -vv
  1. output:

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/PoolsTest.t.sol:PoolsTest
[PASS] testUsersCanRepayDebtsEvenAfterGracePeriod() (gas: 1165359)
Logs:
After depositNFT...
scaledDebtBalance: 0
After borrow...
scaledDebtBalance: 900000000000000000000
Alice's health factor: 888888888888888888
Alice debt before repay: 900000000000000000000
Alice debt after repay: 0
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.10ms (1.36ms CPU time)
Ran 1 test suite in 10.95ms (7.10ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Impact

  • Denial of Service (DoS) in Liquidation:
    Under-liquidation users can repay their debts, which resets their debt balance to zero. This unintended behavior prevents the Stability Pool from finalizing liquidation since the expected debt-burning logic cannot execute on a zero debt value.

  • Systemic Integrity Risk:
    The failure to finalize liquidation leaves users stuck in an under-liquidation state. This not only locks their collateral but also potentially disrupts the liquidity and risk management mechanisms of the protocol.

  • User Blacklisting:
    Affected users may become effectively blacklisted, as they cannot withdraw collateral, initiate new borrowings, or exit liquidation via the prescribed process, leading to long-term operational issues.

  • Adversary Opportunity:
    Malicious actors could exploit this vulnerability to disrupt the system, potentially causing cascading effects that impact other users and the protocol's overall stability. An attacker could intentionally lock users in under-liquidation states, leading to systemic issues.

Tools Used

  • Manual Review

  • Foundry (Forge)

Recommendations

To remediate this vulnerability, modify the repayment functions to prevent under-liquidation users from repaying their debts via the standard repay and repayOnBehalf functions. Only healthy users should be allowed to reduce their debt outside of the liquidation process. A possible approach is to add a check that prevents repayment if the caller (or the on-behalf user) is flagged as under liquidation.

Recommended Diff

In repay Function

Add a check to ensure that only healthy (non-under-liquidation) users can repay their own debt:

@@ In LendingPool.sol, function repay:
- _repay(amount, msg.sender);
+ if (isUnderLiquidation[msg.sender] && block.timetamp > liquidationStartTime[msg.sender] + liquidationGracePeriod) revert CannotRepayUnderLiquidation();
+ _repay(amount, msg.sender);

In repayOnBehalf Function

Similarly, restrict repayments on behalf of users under liquidation:

@@ In LendingPool.sol, function repayOnBehalf:
- _repay(amount, onBehalfOf);
+ if (isUnderLiquidation[msg.sender] && block.timetamp > liquidationStartTime[msg.sender] + liquidationGracePeriod) revert CannotRepayUnderLiquidation();
+ if (isUnderLiquidation[onBehalfOf] && block.timetamp > liquidationStartTime[onBehalfOf] + liquidationGracePeriod) revert CannotRepayUnderLiquidation();
+ _repay(amount, onBehalfOf);

By enforcing these additional checks, the protocol will ensure that once a user is under liquidation, they must follow the proper liquidation process rather than bypassing it through debt repayment. This preserves the intended mechanics of liquidation, allowing the Stability Pool to finalize the process correctly and preventing users from inadvertently or maliciously locking themselves out of the system.

After implementing these changes, rerun the test suite to verify that under-liquidation users cannot repay their debts via these functions, thereby restoring the intended liquidation flow.

Updates

Lead Judging Commences

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

Appeal created

theirrationalone Submitter
7 months ago
inallhonesty Lead Judge
7 months ago
inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

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

Give us feedback!