Core Contracts

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

Denial of Service via Unbounded Loop in NFT Array Management in LendingPool Contract

Summary

The LendingPool contract allows users to deposit RAACHouse NFTs as collateral via depositNFT and later withdraw them using withdrawNFT or have them transferred during liquidation via finalizeLiquidation. However, both functions use for loops to iterate over the user's nftTokenIds array. Over time, as more NFTs are deposited through depositNFT (which simply pushes each tokenId into the array), this array can grow very large. The unbounded looping in withdrawNFT and finalizeLiquidation can then lead to out-of-gas errors, effectively causing a denial of service (DoS) for users with many deposited NFTs.

Vulnerability Details

How It Begins

  1. NFT Deposit Process:

    In depositNFT, each deposited NFT is appended to the user's nftTokenIds array:

    function depositNFT(uint256 tokenId) external nonReentrant whenNotPaused {
    // update state
    ReserveLibrary.updateReserveState(reserve, rateData);
    if (raacNFT.ownerOf(tokenId) != msg.sender) revert NotOwnerOfNFT();
    UserData storage user = userData[msg.sender];
    if (user.depositedNFTs[tokenId]) revert NFTAlreadyDeposited();
    user.nftTokenIds.push(tokenId);
    user.depositedNFTs[tokenId] = true;
    raacNFT.safeTransferFrom(msg.sender, address(this), tokenId);
    emit NFTDeposited(msg.sender, tokenId);
    }
  2. Inefficient Removal in withdrawNFT:

    When a user withdraws an NFT, the contract iterates over the entire nftTokenIds array to locate the tokenId and then swaps it with the last element before popping it:

    // @info: denial of service could occur over time
    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;
    }
    }

    As the array grows, this loop can consume significant gas.

  3. Batch Processing in finalizeLiquidation:

    Similarly, during liquidation finalization, the contract loops over the entire nftTokenIds array to transfer each NFT to the 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);
    }
    delete user.nftTokenIds;

    When a user has deposited many NFTs, this operation becomes prohibitively expensive in gas.

How the Issue Arises

An attacker or even an honest user who frequently deposits NFTs can grow their nftTokenIds array arbitrarily large. When they (or the Stability Pool, in the case of liquidation) later trigger a withdrawal or liquidation finalization, the for loop iterates over a massive array. The gas required for these operations may exceed the block gas limit, effectively causing a denial of service for that user.

Proof of Concept

Test Suite Walkthrough and Scenario Example

  1. Scenario Example:

    • NFT Deposits:
      Alice deposits reserve assets into the LendingPool and then proceeds to deposit 3,292 NFTs (each with a small house price of 10e18) as collateral. This causes her nftTokenIds array to grow to 3,292 elements.

    • Withdrawal/Finalization DoS:
      When Alice (or the Stability Pool in the case of liquidation) later calls withdrawNFT or finalizeLiquidation, the function iterates over the large array. As demonstrated in the test, the 3,293rd call runs out of gas, effectively locking her funds.

  2. Test Suite Code:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import {Test, console} from "forge-std/Test.sol";
import {StabilityPool} from "../src/core/pools/StabilityPool/StabilityPool.sol";
import {LendingPool} from "../src/core/pools/LendingPool/LendingPool.sol";
import {DEToken} from "../src/core/tokens/DEToken.sol";
import {RToken} from "../src/core/tokens/RToken.sol";
import {DebtToken} from "../src/core/tokens/DebtToken.sol";
import {RAACToken} from "../src/core/tokens/RAACToken.sol";
import {RAACMinter} from "../src/core/minters/RAACMinter/RAACMinter.sol";
import {RAACHousePrices} from "../src/core/primitives/RAACHousePrices.sol";
import {RAACNFT} from "../src/core/tokens/RAACNFT.sol";
import {CurveCrvUSDVaultMock} from "./mocks/CurveCrvUSDVaultMock.m.sol";
import {crvUSDToken} from "../src/mocks/core/tokens/crvUSDToken.sol";
import {ERC20Mock} from "../src/mocks/core/tokens/ERC20Mock.sol";
import {ILendingPool} from "../src/interfaces/core/pools/LendingPool/ILendingPool.sol";
import {PercentageMath} from "../src/libraries/math/PercentageMath.sol";
import {WadRayMath} from "../src/libraries/math/WadRayMath.sol";
contract PoolsTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
DEToken deToken;
RToken rToken;
DebtToken debtToken;
RAACToken raacToken;
RAACMinter raacMinter;
RAACHousePrices raacHousePrices;
RAACNFT raacNft;
CurveCrvUSDVaultMock curveCrvUsdVault;
crvUSDToken crvUsdToken;
ERC20Mock erc20Mock;
// owners
address STABILITY_POOL_OWNER = makeAddr("STABILITY_POOL_OWNER");
address LENDING_POOL_OWNER = makeAddr("LENDING_POOL_OWNER");
address DETOKEN_OWNER = makeAddr("DETOKEN_OWNER");
address RTOKEN_OWNER = makeAddr("RTOKEN_OWNER");
address DEBT_TOKEN_OWNER = makeAddr("DEBT_TOKEN_OWNER");
address RAAC_TOKEN_OWNER = makeAddr("RAAC_TOKEN_OWNER");
address RAAC_MINTER_DUMMY = makeAddr("RAAC_MINTER_DUMMY");
address RAAC_HOUSE_PRICES_OWNER = makeAddr("RAAC_HOUSE_PRICES_OWNER");
address RAAC_HOUSE_PRICES_ORACLE = makeAddr("RAAC_HOUSE_PRICES_ORACLE");
address CRV_USD_TOKEN_OWENR = makeAddr("CRV_USD_TOKEN_OWENR");
address CURVE_CRV_USD_VAULT_OWENR = makeAddr("CURVE_CRV_USD_VAULT_OWENR");
address ERC20_MOCK_TOKEN_OWNER = makeAddr("ERC20_MOCK_TOKEN_OWNER");
address RAAC_NFT_TOKEN_OWNER = makeAddr("RAAC_NFT_TOKEN_OWNER");
address RAAC_MINTER_OWNER = makeAddr("RAAC_MINTER_OWNER");
address RAAC_OWNER = makeAddr("RAAC_OWNER");
// users
address ALICE = makeAddr("ALICE");
address BOB = makeAddr("BOB");
address CHARLIE = makeAddr("CHARLIE");
address DEVIL = makeAddr("DEVIL");
// managers
address MANAGER_1 = makeAddr("MANAGER_1");
address MANAGER_2 = makeAddr("MANAGER_2");
address MANAGER_3 = makeAddr("MANAGER_3");
address MANAGER_4 = makeAddr("MANAGER_4");
address MANAGER_5 = makeAddr("MANAGER_5");
address MANAGER_6 = makeAddr("MANAGER_6");
address MANAGER_7 = makeAddr("MANAGER_7");
address MANAGER_8 = makeAddr("MANAGER_8");
address MANAGER_9 = makeAddr("MANAGER_9");
address MANAGER_10 = makeAddr("MANAGER_10");
uint256 initialPrimeRate = 5e27;
uint256 initialRaacSwapTaxRateInBps = 200; // 2%, 10000 - 100%
uint256 initialRaacBurnTaxRateInBps = 150; // 1.5%, 10000 - 100%
function setUp() public {
vm.warp(block.timestamp + 1 days + 1);
vm.startPrank(CRV_USD_TOKEN_OWENR);
crvUsdToken = new crvUSDToken(CRV_USD_TOKEN_OWENR);
vm.stopPrank();
vm.startPrank(CURVE_CRV_USD_VAULT_OWENR);
curveCrvUsdVault = new CurveCrvUSDVaultMock(address(crvUsdToken), CURVE_CRV_USD_VAULT_OWENR);
vm.stopPrank();
vm.startPrank(RTOKEN_OWNER);
rToken = new RToken("R_Token_V1", "RTKNV1", RTOKEN_OWNER, address(crvUsdToken));
vm.stopPrank();
vm.startPrank(DETOKEN_OWNER);
deToken = new DEToken("DE_Token_V1", "DETKNV1", DETOKEN_OWNER, address(rToken));
vm.stopPrank();
vm.startPrank(DEBT_TOKEN_OWNER);
debtToken = new DebtToken("DEBT_TOKEN_V1", "DEBTKNV1", DEBT_TOKEN_OWNER);
vm.stopPrank();
vm.startPrank(ERC20_MOCK_TOKEN_OWNER);
erc20Mock = new ERC20Mock("ERC20_MOCK_TOKEN", "ERC20MTKN");
vm.stopPrank();
vm.startPrank(RAAC_HOUSE_PRICES_OWNER);
raacHousePrices = new RAACHousePrices(RAAC_HOUSE_PRICES_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_NFT_TOKEN_OWNER);
raacNft = new RAACNFT(address(erc20Mock), address(raacHousePrices), RAAC_NFT_TOKEN_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_OWNER);
raacToken = new RAACToken(RAAC_OWNER, initialRaacSwapTaxRateInBps, initialRaacBurnTaxRateInBps);
vm.stopPrank();
vm.startPrank(LENDING_POOL_OWNER);
lendingPool = new LendingPool(
address(crvUsdToken),
address(rToken),
address(debtToken),
address(raacNft),
address(raacHousePrices),
initialPrimeRate
);
vm.stopPrank();
vm.startPrank(STABILITY_POOL_OWNER);
stabilityPool = new StabilityPool(STABILITY_POOL_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_MINTER_OWNER);
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), RAAC_MINTER_OWNER);
vm.stopPrank();
vm.startPrank(RAAC_OWNER);
raacToken.setMinter(address(raacMinter));
vm.stopPrank();
vm.startPrank(LENDING_POOL_OWNER);
lendingPool.setCurveVault(address(curveCrvUsdVault));
vm.stopPrank();
vm.startPrank(RTOKEN_OWNER);
rToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(DEBT_TOKEN_OWNER);
debtToken.setReservePool(address(lendingPool));
vm.stopPrank();
vm.startPrank(STABILITY_POOL_OWNER);
stabilityPool.initialize(
address(rToken),
address(deToken),
address(raacToken),
address(raacMinter),
address(crvUsdToken),
address(lendingPool)
);
vm.stopPrank();
crvUsdToken.mint(address(lendingPool), 1000_000_000e18);
}
function testWithdrawNFTCausesDoS() public {
vm.startPrank(RAAC_HOUSE_PRICES_OWNER);
// set oracle
raacHousePrices.setOracle(RAAC_HOUSE_PRICES_ORACLE);
vm.stopPrank();
// ensure alice has sufficient balance
crvUsdToken.mint(ALICE, 1000e18);
vm.startPrank(ALICE);
crvUsdToken.approve(address(lendingPool), 1000e18);
// deposit 1000e18 into lending pool to have some liquidity
lendingPool.deposit(1000e18);
vm.stopPrank();
uint256 housePrice = 10e18;
// call no: 3293 makes tx outOfGas
for (uint256 i = 0; i < 3292; i++) {
vm.startPrank(RAAC_HOUSE_PRICES_ORACLE);
// house price in USD
raacHousePrices.setHousePrice(i, housePrice);
vm.stopPrank();
// alice pays in erc20 to mint nft
erc20Mock.mint(ALICE, housePrice);
vm.startPrank(ALICE);
erc20Mock.approve(address(raacNft), housePrice);
// mint 1 nft with tokenId i
raacNft.mint(i, housePrice);
vm.stopPrank();
vm.startPrank(ALICE);
raacNft.approve(address(lendingPool), i);
// deposit nft into lending pool
lendingPool.depositNFT(i);
vm.stopPrank();
}
(
uint256[] memory nftTokenIds,
uint256 scaledDebtBalance,
uint256 userCollateralValue,
bool isUserUnderLiquidation,
uint256 liquidityIndex,
uint256 usageIndex,
uint256 totalLiquidity,
uint256 totalUsage
) = lendingPool.getAllUserData(ALICE);
console.log("After depositNFT... ");
console.log("nftTokenIds: ", nftTokenIds[0]);
console.log("scaledDebtBalance: ", scaledDebtBalance);
console.log("userCollateralValue: ", userCollateralValue);
console.log("isUserUnderLiquidation: ", isUserUnderLiquidation);
console.log("liquidityIndex: ", liquidityIndex);
console.log("usageIndex: ", usageIndex);
console.log("totalLiquidity: ", totalLiquidity);
console.log("totalUsage: ", totalUsage);
for (uint256 i = 0; i < 3292; i++) {
vm.startPrank(ALICE);
// Revert over time with msg: EvmError: outOfGas
lendingPool.withdrawNFT(i);
vm.stopPrank();
}
}
}

Output Log

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/PoolsTest.t.sol:PoolsTest
[FAIL. Reason: EvmError: Revert] testWithdrawNFTCausesDoS() (gas: 1073716912)
Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 565.84ms (559.04ms CPU time)
Ran 1 test suite in 569.09ms (565.84ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)
Failing tests:
Encountered 1 failing test in test/PoolsTest.t.sol:PoolsTest
[FAIL. Reason: EvmError: Revert] testWithdrawNFTCausesDoS() (gas: 1073716912)
Encountered a total of 1 failing tests, 0 tests succeeded

How to Run the Test

  1. Create a Foundry Project:

    forge init my-foundry-project
  2. Place Contract Files:
    Ensure that all contracts (LendingPool.sol, RAACNFT.sol, RAACHousePrices.sol, etc.) are placed in the src directory.

  3. Create Test Directory:
    Create a test directory adjacent to src and add the test file (e.g., PoolsTest.t.sol).

  4. Run the Test:

forge test --mt testWithdrawNFTCausesDoS -vv

Impact

  • Denial of Service (DoS):
    As the nftTokenIds array grows over time, the gas required to loop through it increases. This can lead to out-of-gas errors when executing withdrawNFT or finalizeLiquidation, preventing users from withdrawing their NFTs or finalizing liquidations.

  • User Lockout and Liquidity Issues:
    Affected users could be locked out of retrieving their collateral, potentially causing broader liquidity and operational problems within the protocol.

  • Scalability Concerns:
    This vulnerability hinders the protocol’s ability to scale, as increased NFT deposits directly correlate with higher gas consumption during critical operations.

Tools Used

  • Manual Review

  • Foundry (Forge)

Recommendations

To mitigate the denial-of-service vulnerability, the NFT management logic should be optimized. One effective solution is to maintain an additional mapping that tracks each NFT's index in the nftTokenIds array. This allows constant-time removal without iterating over the entire array.

Recommended Diff

Update in depositNFT

Add a mapping to track the index of each NFT in the user's array (e.g., nftTokenIdIndex in the UserData struct). Then update the deposit function as follows:

@@ In LendingPool.sol, function depositNFT:
- user.nftTokenIds.push(tokenId);
+ user.nftTokenIdIndex[tokenId] = user.nftTokenIds.length;
+ user.nftTokenIds.push(tokenId);

Update in withdrawNFT

Replace the for loop with a constant-time removal mechanism:

@@ In LendingPool.sol, function withdrawNFT:
- // Remove NFT from user's deposited NFTs
- // @info: denial of service could occur over time
- 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;
- }
- }
+ // Optimized removal using stored index mapping
+ uint256 index = user.nftTokenIdIndex[tokenId];
+ uint256 lastIndex = user.nftTokenIds.length - 1;
+ if (index != lastIndex) {
+ uint256 lastTokenId = user.nftTokenIds[lastIndex];
+ user.nftTokenIds[index] = lastTokenId;
+ user.nftTokenIdIndex[lastTokenId] = index;
+ }
+ user.nftTokenIds.pop();
+ delete user.nftTokenIdIndex[tokenId];

Update in finalizeLiquidation

For batch processing NFT removals, implement a removal limit and track the batch process with a flag. For example, add a state variable (e.g., batchRemovalInProgress) and a mapping to track progress, then limit the number of removals per transaction:

@@ In LendingPool.sol, function finalizeLiquidation:
- for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
- uint256 tokenId = user.nftTokenIds[i];
- user.depositedNFTs[tokenId] = false;
- raacNFT.transferFrom(address(this), stabilityPool, tokenId);
- }
- delete user.nftTokenIds;
+ // Begin batch removal process: remove NFTs in manageable batches
+ // (Assume batchLimit is defined as a constant)
+ uint256 batchLimit = 100; // for example, remove 100 NFTs per transaction
+ uint256 removals = 0;
+ while (user.nftTokenIds.length > 0 && removals < batchLimit) {
+ uint256 tokenId = user.nftTokenIds[user.nftTokenIds.length - 1];
+ user.depositedNFTs[tokenId] = false;
+ raacNFT.transferFrom(address(this), stabilityPool, tokenId);
+ // Remove NFT using the index mapping removal
+ uint256 index = user.nftTokenIdIndex[tokenId];
+ uint256 lastIndex = user.nftTokenIds.length - 1;
+ if (index != lastIndex) {
+ uint256 lastTokenId = user.nftTokenIds[lastIndex];
+ user.nftTokenIds[index] = lastTokenId;
+ user.nftTokenIdIndex[lastTokenId] = index;
+ }
+ user.nftTokenIds.pop();
+ delete user.nftTokenIdIndex[tokenId];
+ removals++;
+ }
+ // If NFTs remain, mark batch removal as in-progress to block other functions until complete
+ if (user.nftTokenIds.length > 0) {
+ batchRemovalInProgress[userAddress] = true;
+ } else {
+ batchRemovalInProgress[userAddress] = false;
+ }

Additionally, lock all functions that depend on NFT state (such as new deposits, withdrawals, or borrow actions) while batchRemovalInProgress[userAddress] is true to ensure consistency.

  1. Integrate Function Locks During Batch Removal

Before processing any NFT-related operation, check if a batch removal is in progress for the user:

require(!batchRemovalInProgress[msg.sender], "Batch removal in progress; try later");

Apply this check in depositNFT, withdrawNFT, and any other functions that interact with the NFT array.

After implementing these changes, rerun the test suite and conduct additional tests to verify that:

  • NFT removals occur in constant time.

  • Batch processing limits gas usage.

  • Functions are properly locked until all NFTs are removed.

By implementing these optimizations and batch removal controls, the protocol will prevent DoS scenarios arising from unbounded loops and improve both efficiency and scalability.

Updates

Lead Judging Commences

inallhonesty Lead Judge 7 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 7 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.

Appeal created

theirrationalone Submitter
7 months ago
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.

Give us feedback!