Core Contracts

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

NFTs Get Permanently Locked in Stability Pool After Liquidation

Summary

When a borrower is liquidated through the finalizeLiquidation function in the LendingPool contract, their NFT collateral is transferred to the StabilityPool. However, there is no functionality in the StabilityPool contract to transfer or withdraw these NFTs, resulting in them being permanently locked in the contract.

Vulnerability Details

The issue occurs in the following sequence:

  1. User deposits his NFT as collateral to borrow liquidity from the LendingPool

  2. Collateral price drops

  3. Someone kicks of the liquidation process

  4. Borrower fails to repay his debt

  5. StabilityPool liquidates the Borrowers position

  6. LiquidityPool sends all Borrower NFTs to the StabilityPool

  7. NFTs are "stuck" now in the StabilityPool

When we look at the finalizeLiquidation() function below we can see that all of the NFTs gets transferred to the StabilityPool:

function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
// ...
// Transfer NFTs to Stability Pool
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
@> raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
// ...
}

The Proof of concept below demonstrates that the NFT get transferred to the StabilityPool, however there is no transfer / withdraw function in the StabilityPool. We can easily verify that there is no way to transfer the NFT by searching for the RAACNFT or IRAACNFT contract which should be provided to the StabilityPool contract in order to transfer/withdraw the NFT.

PoC

This test assumes that the issue in the StabilityPool::liquidateBorrower() function for the approval has been fixed (see issue: "StabilityPool can't liquidate positions because of wrong user debt amount being approved causing the transaction to fail")

For the purpose of this test I modified the function to approve type(uint256).max. This shouldn't be done in production and there is already a recommendation in the issue mentioned above.

Update Line 461 in the liquidateBorrower() function :

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
- bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
+ bool approveSuccess = crvUSDToken.approve(address(lendingPool), type(uint256).max);
}

In order to run the test you need to:

  1. Run foundryup to get the latest version of Foundry

  2. Install hardhat-foundry: npm install --save-dev @nomicfoundation/hardhat-foundry

  3. Import it in your Hardhat config: require("@nomicfoundation/hardhat-foundry");

  4. Make sure you've set the BASE_RPC_URL in the .env file or comment out the forking option in the hardhat config.

  5. Run npx hardhat init-foundry

  6. There is one file in the test folder that will throw an error during compilation so rename the file in test/unit/libraries/ReserveLibraryMock.sol to => ReserveLibraryMock.sol_broken so it doesn't get compiled anymore (we don't need it anyways).

  7. Create a new folder test/foundry

  8. Paste the below code into a new test file i.e.: FoundryTest.t.sol

  9. Run the test: forge test --mc FoundryTest -vvvv

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import {Test} from "forge-std/Test.sol";
import {console2} from "forge-std/console2.sol";
import {StabilityPool} from "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {crvUSDToken} from "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import {RAACToken} from "../../contracts/core/tokens/RAACToken.sol";
import {RAACHousePrices} from "../../contracts/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../../contracts/core/tokens/RAACNFT.sol";
import {RToken} from "../../contracts/core/tokens/RToken.sol";
import {DebtToken} from "../../contracts/core/tokens/DebtToken.sol";
import {DEToken} from "../../contracts/core/tokens/DEToken.sol";
import {LendingPool} from "../../contracts/core/pools/LendingPool/LendingPool.sol";
import {RAACMinter, IRAACMinter} from "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {PercentageMath} from "../../contracts/libraries/math/PercentageMath.sol";
import {ILendingPool} from "../../contracts/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {IStabilityPool} from "../../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
StabilityPool public stabilityPool;
LendingPool public lendingPool;
RAACMinter public raacMinter;
crvUSDToken public crvusd;
RToken public rToken;
DEToken public deToken;
RAACToken public raacToken;
RAACNFT public raacNFT;
DebtToken public debtToken;
RAACHousePrices public raacHousePrices;
address public owner;
address public user1;
address public user2;
address public user3;
address public treasury;
uint256 public constant INITIAL_BALANCE = 1000e18;
uint256 public constant INITIAL_PRIME_RATE = 1e27;
uint256 constant INITIAL_BATCH_SIZE = 3;
uint256 constant HOUSE_PRICE = 100e18;
uint256 constant TOKEN_ID = 1;
function setUp() public {
// Setup accounts
owner = address(this);
user1 = makeAddr("user1");
user2 = makeAddr("user2");
user3 = makeAddr("user3");
treasury = makeAddr("treasury");
// Deploy base tokens
crvusd = new crvUSDToken(owner);
crvusd.setMinter(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy price oracle and set oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner);
// Set initial house prices
raacHousePrices.setHousePrice(TOKEN_ID, HOUSE_PRICE);
// Deploy NFT
raacNFT = new RAACNFT(address(crvusd), address(raacHousePrices), owner);
// Deploy pool tokens
rToken = new RToken("RToken", "RToken", owner, address(crvusd));
debtToken = new DebtToken("DebtToken", "DT", owner);
deToken = new DEToken("DEToken", "DEToken", owner, address(rToken));
// Deploy pools
lendingPool = new LendingPool(
address(crvusd),
address(rToken),
address(debtToken),
address(raacNFT),
address(raacHousePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(owner);
// this is needed otherwise lastEmissionUpdateTimestamp will underflow in the RAACMinter constructor
vm.warp(block.timestamp + 2 days);
// Deploy RAAC minter
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), owner);
// Setup cross-contract references
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));
// Initialize Stability Pool
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvusd),
address(lendingPool)
);
// Setup permissions
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
// Mint initial tokens and setup approvals
// also deposit crvUSD to the stability pool to get rTokens
_setupInitialBalancesAndAllowances();
}
function test_NFTisStuckInStabilityPool() public {
address borrower = makeAddr("borrower");
crvusd.mint(borrower, HOUSE_PRICE);
assertEq(crvusd.balanceOf(borrower), HOUSE_PRICE);
// mint nft to borrower for 100e18
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), HOUSE_PRICE);
raacNFT.mint(TOKEN_ID, HOUSE_PRICE);
assertEq(raacNFT.balanceOf(borrower), 1);
// deposit NFT to the lending pool
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
// Borrow
lendingPool.borrow(HOUSE_PRICE);
assertEq(crvusd.balanceOf(borrower), HOUSE_PRICE);
vm.stopPrank();
// initiate liquidation
lendingPool.initiateLiquidation(borrower);
assertEq(lendingPool.isUnderLiquidation(borrower), true);
// wait for grace period to end
vm.warp(lendingPool.liquidationStartTime(borrower) + lendingPool.liquidationGracePeriod() + 1 seconds);
// give the Stability Pool enough crvUSD to repay
crvusd.mint(address(stabilityPool), 200e18);
// liquidate borrower fails
stabilityPool.liquidateBorrower(borrower);
// check that the borrower's NFT was locked in the Stability Pool
assertEq(raacNFT.balanceOf(address(stabilityPool)), 1);
}
function _setupInitialBalancesAndAllowances() internal {
// Mint crvUSD to users
crvusd.mint(user1, INITIAL_BALANCE);
crvusd.mint(user2, INITIAL_BALANCE);
crvusd.mint(user3, INITIAL_BALANCE);
// Setup approvals for users
vm.startPrank(user1);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user2);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
vm.startPrank(user3);
crvusd.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(INITIAL_BALANCE);
rToken.approve(address(stabilityPool), type(uint256).max);
vm.stopPrank();
}
}

Impact

It's also worth to mention here that during the minting process of the NFT the provided capital is locked in the NFT contract but this should be used to repay the users debt when he gets liquidated (see issue: "Permanent Fund Lock in RAACNFT Contract Due to Missing Fund Distribution Logic").

This combination of locked NFTs and inaccessible minting capital leads to:

  • NFTs used as collateral become permanently locked in the StabilityPool contract after liquidation

  • The value of these NFTs is effectively lost to the protocol and its users

  • The protocol's ability to handle liquidations properly is compromised as the collateral cannot be sold to cover the debt

Tools Used

  • Foundry

  • Manual Review

Recommendations

There are different solutions to this:

  • There could be some kind of auction mechanism after NFT gets send to the StabilityPool

  • The StabilityPool burn / sells the NFT and access the underlying collateral to cover the defaulted debt

  • use safeTransfer to validate that the receiving contract (StabilityPool) has a onERC721Received function

Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

Liquidated RAACNFTs are sent to the StabilityPool by LendingPool::finalizeLiquidation where they get stuck

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.