Core Contracts

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

Incorrect Liquidation Threshold in `LendingPool::borrow` Leading to Undercollateralized Loans

Summary

In the borrowing logic due to an incorrectly set liquidation threshold. The threshold is set too low (80%), allowing users to borrow more than their collateral's value. This results in undercollateralized positions immediately after borrowing, exposing the protocol to the risk of bad debt and enabling immediate liquidations.

Vulnerability Details

The borrow function in the LendingPool contract checks if the user's collateral value is sufficient to cover the new debt using the formula:

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);
if (collateralValue == 0) revert NoCollateral();
// Update reserve state before borrowing
ReserveLibrary.updateReserveState(reserve, rateData);
// Ensure sufficient liquidity is available
_ensureLiquidity(amount);
// Fetch user's total debt after borrowing
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Ensure the user has enough collateral to cover the new debt
@>> if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
@>> revert NotEnoughCollateralToBorrow();
@>> }
// Update user's scaled debt balance
uint256 scaledAmount = amount.rayDiv(reserve.usageIndex);
// Mint DebtTokens to the user (scaled amount)
(bool isFirstMint, uint256 amountMinted, uint256 newTotalSupply) = IDebtToken(reserve.reserveDebtTokenAddress).mint(msg.sender, msg.sender, amount, reserve.usageIndex);
// Transfer borrowed amount to user
IRToken(reserve.reserveRTokenAddress).transferAsset(msg.sender, amount);
user.scaledDebtBalance += scaledAmount;
// reserve.totalUsage += amount;
reserve.totalUsage = newTotalSupply;
// Update liquidity and interest rates
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, 0, amount);
// Rebalance liquidity after borrowing
_rebalanceLiquidity();
emit Borrow(msg.sender, amount);
}

The liquidationThreshold is initialized to 80% (BASE_LIQUIDATION_THRESHOLD = 80 * 1e2). This setup allows the debt to exceed the collateral value, as the calculation effectively permits the debt to be up to 125% of the collateral (since 80% of 125% equals 100%). Consequently, users can borrow amounts that leave their positions undercollateralized from the start, making them immediately eligible for liquidation and risking the protocol's solvency.

POC

The following Hardhat test demonstrates the issue:

describe("LendingPool Borrow Threshold Vulnerability", () => {
let lendingPool, reserveAsset, raacNFT, user;
const NFT_VALUE = ethers.utils.parseEther("100"); // 100 ETH collateral value
const BORROW_AMOUNT = ethers.utils.parseEther("125"); // 125 ETH debt
before(async () => {
[user] = await ethers.getSigners();
// Deploy mock contracts
const ERC20Mock = await ethers.getContractFactory("ERC20Mock");
reserveAsset = await ERC20Mock.deploy("crvUSD", "crvUSD", 18);
const RAACNFT = await ethers.getContractFactory("RAACNFTMock");
raacNFT = await RAACNFT.deploy();
// Deploy LendingPool with 80% liquidation threshold
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(
reserveAsset.address,
/* rToken */ ethers.constants.AddressZero,
/* debtToken */ ethers.constants.AddressZero,
raacNFT.address,
/* priceOracle */ user.address, // Simplified mock
1e27 // Initial prime rate (RAY)
);
// Setup test environment
await raacNFT.mint(user.address, 1); // Mint NFT ID 1 to user
await lendingPool.connect(user).depositNFT(1); // Deposit NFT
});
it("Allows dangerous borrowing at 125% collateralization", async () => {
// Execute vulnerable borrow
await lendingPool.connect(user).borrow(BORROW_AMOUNT);
// Verify undercollateralized position
const healthFactor = await lendingPool.calculateHealthFactor(user.address);
expect(healthFactor).to.be.lt(ethers.utils.parseEther("1")); // HF < 1 = liquidatable
// Confirm immediate liquidation eligibility
await lendingPool.initiateLiquidation(user.address);
expect(await lendingPool.isUnderLiquidation(user.address)).to.be.true;
});
});

Key Value Flow:

1. Collateral Value: 100 ETH
2. Liquidation Threshold: 80% (0.8)
3. Allowed Debt Calculation: 100 ETH / 0.8 = 125 ETH
4. Actual Borrowed Amount: 125 ETH
5. Resulting Health Factor: (100 * 0.8) / 125 = 0.64 (<1.0)

Impact

This issue allows users to create undercollateralized loans, leading to immediate liquidations. Over time, this can result in significant bad debt accumulation if collateral values decrease, threatening the protocol's financial stability. Attackers could exploit this to intentionally create risky positions, draining protocol reserves during liquidations.

Tools Used

Manual Review

Recommendations

  1. Adjust Liquidation Threshold:
    Set BASE_LIQUIDATION_THRESHOLD to a safer value like 125% (125 * 1e2) to ensure collateral covers debt with a buffer:

    uint256 public constant BASE_LIQUIDATION_THRESHOLD = 125 * 1e2; // 125%
  2. Revise Collateral Check Logic:
    Ensure the formula properly represents the collateral-to-debt ratio. The correct check should enforce:

    collateralValue >= userTotalDebt.percentDiv(liquidationThreshold)

    Where percentDiv divides by the threshold percentage to calculate minimum required collateral.

Updates

Lead Judging Commences

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

LendingPool::borrow as well as withdrawNFT() reverses collateralization check, comparing collateral < debt*0.8 instead of collateral*0.8 > debt, allowing 125% borrowing vs intended 80%

Support

FAQs

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