Core Contracts

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

Borrowing Up to 100% of Collateral Breaks Liquidation Threshold and Leads to Undercollateralized Loans

Summary:

The LendingPool contract allows users to borrow up to 100% of their collateral value. This completely bypasses the concept of a liquidation threshold, which is designed to protect the protocol from bad debt. By allowing borrowing up to 100%, the protocol becomes extremely vulnerable to even small price drops in the collateral asset, as there is no buffer to absorb losses before the debt becomes undercollateralized.

Vulnerability Details:

The core function of a lending protocol is to ensure that borrowed amounts are always sufficiently collateralized. The liquidation threshold represents the percentage of the collateral value below which a loan is considered undercollateralized and can be liquidated. This threshold provides a safety margin to protect the protocol against losses in case the collateral value decreases.

The borrow function in the LendingPool contract calculates the user's total debt and checks if it's less than the user's collateral value multiplied by the liquidationThreshold. However, the liquidationThreshold is not being enforced correctly. Users are able to borrow up to 100% of their collateral.

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// ... other checks ...
uint256 collateralValue = getUserCollateralValue(msg.sender); // Get total collateral value
// ... other calculations ...
uint256 userTotalDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex) + amount; // Total debt AFTER borrow
// INCORRECT CHECK: Checks against liquidationThreshold AFTER the borrow
if (collateralValue < userTotalDebt.percentMul(liquidationThreshold)) {
revert NotEnoughCollateralToBorrow(); // This check is too late!
}
// ... proceed with borrowing ...
}

The crucial flaw is that the userTotalDebt calculation includes the amount the user is about to borrow. The check against liquidationThreshold happens after the debt has already been increased by the borrow amount. This means the borrow amount is already added to the user debt when the collateral is checked. As a result, the user can borrow the full amount of the collateral. The collateral is checked against the debt after the borrow has been added to the debt. Since it is added, the check will always pass.

Impact:

  • Bad Debt: The most significant impact is the increased risk of bad debt. Even small price drops in the collateral can leave the protocol holding undercollateralized loans.

  • Loss of Funds: If the collateral value drops significantly, the protocol may not be able to recover the full borrowed amount during liquidation, leading to a loss of funds for the protocol and its lenders.

  • System Instability: The lack of a liquidation threshold makes the protocol highly susceptible to market volatility and can lead to rapid erosion of the protocol's reserves.

Proof of Concept:

  1. Alice deposits 50_000 USD worth of NFT collateral.

  2. The LendingPool allows Alice to borrow up to 50_000 USD worth of a crvUSD.

  3. Alice borrows 50_000 USD worth of a crvUSD.

  4. The price of NFT drops by even a small percentage (e.g., 5%).

  5. Alice's loan is now undercollateralized. Alice has no incentive to repay, as the value of her collateral is now less than her debt.

  6. The protocol is left with bad debt.

Proof Of Code:

  1. Use this guide to intergrate foundry into your project: foundry

  2. Create a new file FortisAudits.t.sol in the test directory.

  3. Add the following gist code to the file: Gist Code

  4. Run the test using forge test --mt test_FortisAudits_BorrowingUpTo100Percent -vvvv.

function test_FortisAudits_BorrowingUpTo100Percent() public {
address lp = makeAddr("lp");
uint256 price = 50_000e18;
uint256 tokenId = 1;
_reserveAsset.mint(lp, price * 2);
rwaToken.mint(anon, price * 2);
// owner setting up the lending pool and minting the tokens
vm.startPrank(initialOwner);
raacHouse.setOracle(initialOwner);
raacHouse.setHousePrice(tokenId, price);
raacHouse.setHousePrice(tokenId+1, price);
debtToken.setReservePool(address(lendingPool));
vm.stopPrank();
// Lp deposits the reserve asset
vm.startPrank(lp);
_reserveAsset.approve(address(lendingPool), price * 2);
lendingPool.deposit(price * 2);
vm.stopPrank();
// Anon deposits the RWA token and mints the NFTs
vm.startPrank(anon);
rwaToken.approve(address(raacNFT), price * 2);
raacNFT.mint(tokenId, price);
raacNFT.setApprovalForAll(address(lendingPool), true);
// Deposit
console.log("Alice deposits 50_000 USD worth of RAAC NFT");
console.log("CrvUSD balance of Alice before borrow: %d crvUSD", _reserveAsset.balanceOf(anon));
lendingPool.depositNFT(1);
// Borrow
console.log("Alice borrows 50_000 USD (100% of collateral value) worth of CrvUSD");
lendingPool.borrow(price);
console.log("CrvUSD balance of Alice after borrow: %d crvUSD", _reserveAsset.balanceOf(anon));
vm.stopPrank();
}
[PASS] test_FortisAudits_BorrowingUpTo100Percent() (gas: 987309)
Logs:
Alice deposits 50_000 USD worth of RAAC NFT
CrvUSD balance of Alice before borrow: 0 crvUSD
Alice borrows 50_000 USD (100% of collateral value) worth of CrvUSD
CrvUSD balance of Alice after borrow: 50000000000000000000000 crvUSD

Tools Used:

  • Manual code review.

Recommended Mitigation:

The borrow function should be modified to enforce the liquidation threshold before the borrow amount is added to the user's debt. The check should be against the current debt plus the requested borrow amount. This corrected logic will ensure that users cannot borrow beyond the liquidation threshold, protecting the protocol from undercollateralized debt and making all the loans overcollateralized.

function borrow(uint256 amount) external nonReentrant whenNotPaused onlyValidAmount(amount) {
// ... other checks ...
uint256 collateralValue = getUserCollateralValue(msg.sender);
// ... other calculations ...
+ uint256 currentDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex); // Current debt
+ uint256 maxBorrowable = (collateralValue * liquidationThreshold) / 100; // Calculate max borrowable
+ if (amount > maxBorrowable) {
+ revert InsufficientCollateral(); // Prevent borrowing above the limit
+ }
uint256 userTotalDebt = currentDebt + amount; // Total debt AFTER borrow (now safe)
// ... proceed with borrowing ...
}
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.