Core Contracts

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

Incorrect Liquidation Threshold in `LendingPool::withdrawNFT` Leads to Undercollateralized Positions

Summary

In the withdrawNFT function where the liquidation threshold is applied incorrectly, allowing users to withdraw NFT collateral even if it leaves their position undercollateralized. The protocol uses a multiplicative check (debt * threshold) instead of the required reciprocal relationship (debt / threshold), violating the core lending/borrowing safety invariant.

Vulnerability Details

The liquidation threshold is designed to enforce a minimum collateral-to-debt ratio (e.g., 125% collateral for an 80% LTV threshold). However, the code erroneously checks:

if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold))

This calculates debt * threshold (e.g., $100 * 80% = $80) instead of the correct debt / threshold (e.g., $100 / 80% = $125). This allows withdrawals that reduce collateral below the safe threshold.

Key Code Snippet

// ❌ Flawed check: Uses (debt * threshold) instead of (debt / threshold)
if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}

Proof of Concept (PoC)

// Test Setup:
// - Liquidation Threshold: 80% (8000 basis points)
// - User deposits 5 NFTs (25 crvUSD each → 125 total)
// - User borrows 100 crvUSD (safe: 125 >= 100/0.8 = 125)
it("Allows undercollateralized NFT withdrawal", async () => {
// Withdraw 1 NFT (25 crvUSD)
await lendingPool.connect(user).withdrawNFT(1);
// New Collateral = 100 crvUSD, Debt = 100 crvUSD
const healthFactor = await lendingPool.calculateHealthFactor(user.address);
// Health Factor = (100 * 80%) / 100 = 0.8e18 (< 1e18 threshold)
expect(healthFactor).to.be.lt(ethers.utils.parseEther("1.0"));
// ❌ Position is undercollateralized but remains active
});

Impact

  • Critical Severity: Users can systematically drain collateral while maintaining debt, creating undercollateralized positions that should have been liquidated.

  • Example Attack Flow (Liquidation Threshold = 80%):

    1. Initial State:

      • Collateral: 125 crvUSD (5 NFTs @ 25 each)

      • Debt: 100 crvUSD

      • Required Collateral: 100 / 80% = 125 crvUSD ✅

    2. Withdraw 1 NFT (25 crvUSD):

      • New Collateral: 100 crvUSD

      • Required Collateral: 100 / 80% = 125 crvUSD ❌

      • Protocol Check: 100 < (100 * 80%) → 100 < 80 → False → Allows withdrawal

    3. Result:

      • Position is undercollateralized (100 crvUSD collateral for 100 crvUSD debt → 100% LTV) but remains active.

Accumulation of undercollateralized positions threatens protocol solvency during market downturns.

Tools Used

Manual Review

Recommendations

Change the collateral check to use the reciprocal of the liquidation threshold:

// ✅ Corrected check: (collateral - nftValue) >= (debt / threshold)
uint256 requiredCollateral = userDebt.percentDiv(liquidationThreshold);
if ((collateralValue - nftValue) < requiredCollateral) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
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.