Core Contracts

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

StabilityPool requires more crvUSD than required when liquidating a borrower

Summary

When StabilityPool liquidates a borrower, it needs some crvUSD to cover borrower's total debt. However, crvUSD amount calculation is done incorrectly. StabiltyPool requires more crvUSD than user's actual debt.

This will lead to potential liquidation failure. Moreover, excess crvUSD will be stuck at StabilityPool.

Vulnerability Details

Root Cause Analysis

StabilityPool needs to hold some crvUSD to cover user's total debt who is being liquidated.

If current crvUSD balance is below than user's total debt, it is expected to revert.

However, required crvUSD balance calculation is incorrect:

uint256 userDebt = lendingPool.getUserDebt(userAddress);
@> uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt()); // @audit double multiplication of usageIndex
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();

To see why the calculation is incorrect. Let's check how userDebtand scaledUserDebt variable is calculated.

Below is LendingPool.getUserDebt implementation:

function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}

In otherwords, userDebt variable will be calculated as:

userDebt = user.scaledDebtBalance * usageIndex

Thus, scaledUserDebt will be calculated like the following:

scaledUserDebt = userDebt * lendingPool.getNormalizedDebt()
= user.scaledDebtBalance * usageIndex * usageIndex

So scaledUserDebt is inflated by usageIndex - 1 times.

Due to the balance check, liquidateBorrower will revert if a manager supplies exact amount of crvUSD as user's debt.

In order to finalize the liquidation, managers are forced to deposit more crvUSD.

But LendingPool will only spend exact amount of user's debt, so excess crvUSD will be stuck at StabilityPool.

POC

Scenario

  • User deposits NFT worth of 100_000 USD

  • User borrows 80_000 USD

  • 30 days pass and user's health factor drops below liquidation threshold

  • LendingPool initiates user's liquidation

  • Grace period (3 days) passes

  • StabilityPool has exact amount of crvUSD as user's total debt

  • StabilityPool tries to liquidate user's position but reverts with InsufficientBalance error

  • StabilityPool prepares more crvUSD

  • This time liquidation is done successfully, but excess crvUSD (236 USD) is stuck at StabilityPool

How to run POC

pragma solidity ^0.8.19;
import "../lib/forge-std/src/Test.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 {RAACToken} from "../contracts/core/tokens/RAACToken.sol";
import {RAACNFT} from "../contracts/core/tokens/RAACNFT.sol";
import {LendingPool} from "../contracts/core/pools/LendingPool/LendingPool.sol";
import {StabilityPool} from "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import {IStabilityPool} from "../contracts/interfaces/core/pools/StabilityPool/IStabilityPool.sol";
import {RAACMinter} from "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import {crvUSDToken} from "../contracts/mocks/core/tokens/crvUSDToken.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RAACHousePricesMock {
mapping(uint256 => uint256) public prices;
function getLatestPrice(uint256 tokenId) external view returns (uint256, uint256) {
return (prices[tokenId], block.timestamp);
}
function setTokenPrice(uint256 tokenId, uint256 price) external {
prices[tokenId] = price;
}
function tokenToHousePrice(uint256 tokenId) external view returns (uint256) {
return prices[tokenId];
}
}
contract LendingPoolTest is Test {
using WadRayMath for uint256;
RToken rtoken;
DebtToken debtToken;
RAACToken raacToken;
DEToken deToken;
RAACNFT raacNft;
RAACMinter raacMinter;
crvUSDToken asset;
LendingPool lendingPool;
StabilityPool stabilityPool;
RAACHousePricesMock housePrice;
address depositor = makeAddr("depositor");
address borrower = makeAddr("borrower");
address user = makeAddr("user");
uint256 userAssetAmount = 10_000e18;
uint256 nftPrice = 50_000e18;
uint256 initialBurnTaxRate = 50;
uint256 initialSwapTaxRate = 100;
uint256 initialPrimeRate = 0.1e27;
function setUp() external {
vm.warp(1e9); // warp time stamp to avoid underflow in RAACMinter constructor
asset = new crvUSDToken(address(this));
housePrice = new RAACHousePricesMock();
debtToken = new DebtToken("DebtToken", "DTK", address(this));
rtoken = new RToken("RToken", "RTK", address(this), address(asset));
raacNft = new RAACNFT(address(asset), address(housePrice), address(this));
lendingPool = new LendingPool(
address(asset), address(rtoken), address(debtToken), address(raacNft), address(housePrice), 0.1e27
);
rtoken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
deToken = new DEToken("DEToken", "DET", address(this), address(rtoken));
raacToken = new RAACToken(address(this), initialSwapTaxRate, initialBurnTaxRate);
raacToken.setMinter(address(this));
stabilityPool = new StabilityPool(address(this));
stabilityPool.initialize(
address(rtoken), address(deToken), address(raacToken), address(this), address(asset), address(lendingPool)
);
lendingPool.setStabilityPool(address(stabilityPool));
raacMinter = new RAACMinter(address(raacToken), address(stabilityPool), address(lendingPool), address(this));
stabilityPool.setRAACMinter(address(raacMinter));
deToken.setStabilityPool(address(stabilityPool));
uint256 depositorAmount = userAssetAmount * 100;
deal(address(asset), depositor, depositorAmount);
vm.startPrank(depositor);
asset.approve(address(lendingPool), depositorAmount);
lendingPool.deposit(depositorAmount);
vm.stopPrank();
}
function testLiquidation() external {
_mintNFT(user, 1, 100_000e18);
_depositNFT(user, 1);
_borrow(user, 80_000e18);
skip(30 days);
lendingPool.initiateLiquidation(user);
// grace period passes
skip(3 days + 1);
lendingPool.updateState();
// set StabilityPool's crvUSD balance to user's total debt
deal(address(asset), address(stabilityPool), lendingPool.getUserDebt(user));
// liquidation fails due to vulnerability
vm.expectRevert(IStabilityPool.InsufficientBalance.selector);
stabilityPool.liquidateBorrower(user);
// set StabilityPool's crvUSD balance to user's total debt * usageIndex
deal(
address(asset),
address(stabilityPool),
lendingPool.getUserDebt(user).rayMul(lendingPool.getNormalizedDebt())
);
// liquidation succeeds
stabilityPool.liquidateBorrower(user);
// user's debt is cleared
assertEq(debtToken.balanceOf(user), 0);
// however StabilityPool is left with stuck crvUSD
uint256 stabilityBalance = asset.balanceOf(address(stabilityPool));
emit log_named_decimal_uint("stability balance", stabilityBalance, 18);
assertGt(stabilityBalance, 0);
}
function _mintNFT(address account, uint256 tokenId, uint256 price) internal {
housePrice.setTokenPrice(tokenId, price);
deal(address(asset), account, price);
vm.startPrank(account);
asset.approve(address(raacNft), price);
raacNft.mint(tokenId, price);
vm.stopPrank();
assertEq(raacNft.ownerOf(tokenId), account);
}
function _depositNFT(address account, uint256 tokenId) internal {
vm.startPrank(account);
raacNft.approve(address(lendingPool), tokenId);
lendingPool.depositNFT(tokenId);
vm.stopPrank();
assertEq(raacNft.ownerOf(tokenId), address(lendingPool));
}
function _borrow(address account, uint256 amount) internal {
uint256 beforeBalance = asset.balanceOf(account);
vm.startPrank(account);
lendingPool.borrow(amount);
vm.stopPrank();
uint256 afterBalance = asset.balanceOf(account);
assertEq(afterBalance - beforeBalance, amount);
}
}

Console Output

[PASS] testLiquidation() (gas: 1118068)
Logs:
stability balance: 236.107020318670338040

Impact

Liquidation needs more crvUSD than expected amount, and excess crvUSD cannot be redeemed.

This will lead to protocol's fund loss.

Tools Used

Manual Review, Foundry

Recommendations

scaledUserDebt variable is unnecessary.

diff --git a/contracts/core/pools/StabilityPool/StabilityPool.sol b/contracts/core/pools/StabilityPool/StabilityPool.sol
index 9820fc6..6153604 100644
--- a/contracts/core/pools/StabilityPool/StabilityPool.sol
+++ b/contracts/core/pools/StabilityPool/StabilityPool.sol
@@ -449,20 +449,19 @@ contract StabilityPool is IStabilityPool, Initializable, ReentrancyGuard, Ownabl
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
// Get the user's debt from the LendingPool.
uint256 userDebt = lendingPool.getUserDebt(userAddress);
- uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
- if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
+ if (crvUSDBalance < userDebt) revert InsufficientBalance();
// Approve the LendingPool to transfer the debt amount
- bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
+ bool approveSuccess = crvUSDToken.approve(address(lendingPool), userDebt);
if (!approveSuccess) revert ApprovalFailed();
// Call finalizeLiquidation on LendingPool
lendingPool.finalizeLiquidation(userAddress);
- emit BorrowerLiquidated(userAddress, scaledUserDebt);
+ emit BorrowerLiquidated(userAddress, userDebt);
}
}
Updates

Lead Judging Commences

inallhonesty Lead Judge 4 months ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool::liquidateBorrower double-scales debt by multiplying already-scaled userDebt with usage index again, causing liquidations to fail

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.