Core Contracts

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

StabilityPool cannot liquidate positions, which can lead to protocol insolvency

Summary

StabilityPool is permanently prevented from liquidating any account due to the incorrect math and invalid state when calling LendingPool.finalizeLiquidation.

Vulnerability Details

Two root causes prevent the liquidation process from working. Let's analyze them:

1st: The amount to be approved representing the user's debt is scaled twice, meaning that if the amount of tokens to pay the user debt is 100, the protocol accounts for 200. This happens because the lendingPool.getUserDebtreturns the scaled amount and then liquidateBorrower applies it once again:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
// Get the user's debt from the LendingPool.
@> uint256 userDebt = lendingPool.getUserDebt(userAddress);
// @audit scaledUserDebt == debt * 2
@> uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
...
}
// LendingPool.sol
function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
@> return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}
// @audit notice normalizedDebt == reserve.usageIndex
function getNormalizedDebt() external view returns (uint256) {
return reserve.usageIndex;
}

Due to this incorrect math, now the liquidation process is vulnerable to DoS:

If the amount of crvUSD balance in StabilityPool is not >= userDebt*2, the protocol will not be able to liquidate the undercollateralized position.

Let's run the PoC below to prove this first issue:

  1. Install foundry through:

    • npm i --save-dev @nomicfoundation/hardhat-foundry

    • Add require("@nomicfoundation/hardhat-foundry");on hardhat config file

    • Run npx hardhat init-foundry and forge install foundry-rs/forge-std --no-commit

  2. Create a file called StabilityPool.t.solin the test folder

  3. Paste the code below:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import "../contracts/core/governance/boost/BoostController.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/interfaces/core/tokens/IveRAACToken.sol";
import "../contracts/core/pools/StabilityPool/StabilityPool.sol";
import "../contracts/core/pools/LendingPool/LendingPool.sol";
import "../contracts/core/tokens/RToken.sol";
import "../contracts/core/tokens/DebtToken.sol";
import "../contracts/core/tokens/DEToken.sol";
import "../contracts/core/tokens/RAACToken.sol";
import "../contracts/core/tokens/RAACNFT.sol";
import "../contracts/core/minters/RAACMinter/RAACMinter.sol";
import "../contracts/libraries/math/WadRayMath.sol";
import "../contracts/core/primitives/RAACHousePrices.sol";
import "../contracts/mocks/core/tokens/crvUSDToken.sol";
contract StabilityPoolTest is Test {
using WadRayMath for uint256;
// contracts
StabilityPool stabilityPool;
LendingPool lendingPool;
RToken rToken;
DEToken deToken;
DebtToken debtToken;
RAACMinter raacMinter;
crvUSDToken crvUSD;
RAACToken raacToken;
RAACHousePrices public raacHousePrices;
RAACNFT public raacNFT;
// users
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
address user3 = address(4);
address[] users = new address[](3);
function setUp() public {
// setup users
users[0] = user1;
users[1] = user2;
users[2] = user3;
vm.label(user1, "USER1");
vm.label(user2, "USER2");
vm.label(user3, "USER3");
// initiate timestamp and block
vm.warp(1738798039); // 2025-02-05
vm.roll(100); // block
vm.startPrank(owner);
_deployAndSetupContracts();
vm.stopPrank();
_mintCrvUsdTokenToUsers(1000e18);
}
function test_WhenCallingLiquidate_shouldRevert_dueToDoubleAmountAndInvalidState() public {
// pre conditions
// 1. Users deposit crvUSD into the pool, minting RTokens
// 2. User borrow funds and become liquidatable
_depositCrvUsdIntoLendingPoolForAllUsers(100e18); // total 300 tokens
_depositNftBorrowFundsAndMakeUserLiquidatable(user1, 1, 300e18);
// action: StabilityPool try to liquidate user
// expected: user is liquidated.
// result:
// Issue 1 - the amount to liquidated is doubled.
// Issue 2 - The state from lending pool it not up-to-date causing an insufficient token approval.
// DoS for both issues.
vm.startPrank(owner);
// liquidate user
lendingPool.initiateLiquidation(user1);
// pass grace period
_advanceInTime(3 days);
lendingPool.updateState();
// 1 sec after updating the pool's state, we call liquidateBorrower.
_advanceInTime(1 seconds);
// user scaled debt
_printUserDebt();
// fund stability pool with crvUSD to cover the debt
deal(address(crvUSD), address(stabilityPool), lendingPool.getUserDebt(user1));
vm.expectRevert(IStabilityPool.InsufficientBalance.selector);
stabilityPool.liquidateBorrower(user1);
vm.stopPrank();
}
function _printUserDebt() internal view {
uint256 userDebt = lendingPool.getUserDebt(user1);
console.log("user scaled debt: %e", userDebt);
uint256 scaledUserDebtFromStabilityPool = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
console.log("scaledUserDebtFromStabilityPool: %e", scaledUserDebtFromStabilityPool);
}
// HELPER FUNCTIONS
function _deployAndSetupContracts() internal {
// Deploy base tokens
crvUSD = new crvUSDToken(owner);
raacToken = new RAACToken(owner, 100, 50);
// Deploy real oracle
raacHousePrices = new RAACHousePrices(owner);
raacHousePrices.setOracle(owner); // Set owner as oracle
// Deploy real NFT contract
raacNFT = new RAACNFT(
address(crvUSD),
address(raacHousePrices),
owner
);
// Deploy core contracts with proper constructor args
rToken = new RToken(
"RToken",
"RTK",
owner,
address(crvUSD)
);
deToken = new DEToken(
"DEToken",
"DET",
owner,
address(rToken)
);
debtToken = new DebtToken(
"DebtToken",
"DEBT",
owner
);
// Deploy pools with required constructor parameters
lendingPool = new LendingPool(
address(crvUSD), // reserveAssetAddress
address(rToken), // rTokenAddress
address(debtToken), // debtTokenAddress
address(raacNFT), // raacNFTAddress
address(raacHousePrices), // priceOracleAddress
0.8e27 // initialPrimeRate (RAY)
);
// Deploy RAACMinter with valid constructor args
raacMinter = new RAACMinter(
address(raacToken),
address(0x1234324423), // stability pool
address(lendingPool),
owner
);
stabilityPool = new StabilityPool(owner);
stabilityPool.initialize(
address(rToken), // _rToken
address(deToken), // _deToken
address(raacToken), // _raacToken
address(raacMinter), // _raacMinter
address(crvUSD), // _crvUSDToken
address(lendingPool) // _lendingPool
);
raacMinter.setStabilityPool(address(stabilityPool));
lendingPool.setStabilityPool(address(stabilityPool));
rToken.setReservePool(address(lendingPool));
debtToken.setReservePool(address(lendingPool));
rToken.transferOwnership(address(lendingPool));
debtToken.transferOwnership(address(lendingPool));
deToken.setStabilityPool(address(stabilityPool));
deToken.transferOwnership(address(stabilityPool));
// setup raacToken's minter and whitelist
raacToken.setMinter(address(raacMinter));
raacToken.manageWhitelist(address(stabilityPool), true);
}
function _depositCrvUsdIntoLendingPoolForAllUsers(uint256 initialDeposit) internal {
// iterate users array and deposit into lending pool
for (uint i = 0; i < users.length; i++) {
vm.prank(users[i]);
lendingPool.deposit(initialDeposit);
}
}
function _mintCrvUsdTokenToUsers(uint256 initialBalance) internal {
for (uint i = 0; i < users.length; i++) {
_mintCrvUsdTokenToUser(initialBalance, users[i]);
}
}
function _mintCrvUsdTokenToUser(uint256 initialBalance, address user) internal {
vm.prank(owner);
crvUSD.mint(user, initialBalance);
vm.startPrank(user);
crvUSD.approve(address(raacNFT), initialBalance);
crvUSD.approve(address(lendingPool), initialBalance);
rToken.approve(address(stabilityPool), initialBalance);
vm.stopPrank();
}
function _advanceInTime(uint256 time) internal {
vm.warp(block.timestamp + time);
vm.roll(block.number + 10000);
}
function _advanceInTimeAndAccrueInterestInLendingPool(uint256 time) internal {
uint256 usageIndex = lendingPool.getNormalizedDebt();
_advanceInTime(time);
lendingPool.updateState();
}
function _setupHousePrices(uint256 housePrice) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(1, housePrice);
raacHousePrices.setHousePrice(2, housePrice);
raacHousePrices.setHousePrice(3, housePrice);
vm.stopPrank();
}
function _setupHousePrice(uint256 housePrice, uint256 newValue) internal {
vm.startPrank(owner);
raacHousePrices.setHousePrice(newValue, housePrice);
vm.stopPrank();
}
function _mintNFTwithTokenId(uint256 tokenId, uint256 housePrice) internal {
raacNFT.mint(tokenId, housePrice);
raacNFT.approve(address(lendingPool), tokenId);
}
function _mintAndDepositNftInLendingPool(uint256 tokenId, uint256 housePrice) internal {
_mintNFTwithTokenId(tokenId, housePrice);
lendingPool.depositNFT(tokenId);
}
function _borrowCrvUsdTokenFromLendingPool(uint256 amount) internal {
lendingPool.borrow(amount);
}
function _depositNftBorrowFundsAndMakeUserLiquidatable(address user, uint256 tokenId, uint256 nftPrice) internal {
_setupHousePrice(nftPrice, tokenId);
vm.startPrank(user);
_mintAndDepositNftInLendingPool(tokenId, nftPrice);
_borrowCrvUsdTokenFromLendingPool(nftPrice/2);
vm.stopPrank();
// accrue interest
_advanceInTimeAndAccrueInterestInLendingPool(365 days);
// house price drops, // user is now liquidatable.
_setupHousePrice(nftPrice/2, tokenId);
}
}

Run: forge test --match-test test_WhenCallingLiquidate_shouldRevert_dueToDoubleAmountAndInvalidState -vv

Output:

Ran 1 test for test/LiquidationIssues.t.sol:StabilityPoolTest
[PASS] test_WhenCallingLiquidate_shouldRevert_dueToDoubleAmountAndInvalidState() (gas: 1361045)
Logs:
user scaled debt: 2.67832328135071613547e20
scaledUserDebtFromStabilityPool: 4.78227706628351156161e20

Ok here we can see that the amount to be approved is doubled and the StabilityPool might not have the amount in crvUSD, thus causing the DoS.

Now let's analyze the second issue, but first we need to fix the incorrect scaling:

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
// Get the user's debt from the LendingPool.
- uint256 userDebt = lendingPool.getUserDebt(userAddress);
- uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
-
- if (scaledUserDebt == 0) revert InvalidAmount();
+ uint256 scaledUserDebt = lendingPool.getUserDebt(userAddress);
+ if (scaledUserDebt == 0) revert InvalidAmount();

Let's remove the line with vm.expectRevert from the PoC to see the error caused by the 2nd issue:

function test_WhenCallingLiquidate_shouldRevert_dueToDoubleAmountAndInvalidState() public {
...
- vm.expectRevert(IStabilityPool.InsufficientBalance.selector);
...
}

Then run: forge test --match-test test_WhenCallingLiquidate_shouldRevert_dueToDoubleAmountAndInvalidState -vvvv

Result: Error because the amount of tokens requested by the LendingPool > the amount approved by the StabilityPool.

[1637422] StabilityPoolTest::test_WhenCallingLiquidate_shouldRevert_dueToDoubleAmountAndInvalidState()
...
├─ [276337] StabilityPool::liquidateBorrower(USER1: [0x0000000000000000000000000000000000000002])
├─ [85287] LendingPool::finalizeLiquidation(USER1: [0x0000000000000000000000000000000000000002])
├─ [1742] crvUSDToken::transferFrom(StabilityPool: [0x94EDc320466d68c0e80C3e6F454375Fb957e1038], RToken: [0x83769BeEB7e5405ef0B7dc3C66C43E3a51A6d27f], 267832333018493419789 [2.678e20])
│ │ │ └─ ← [Revert] ERC20InsufficientAllowance(0x062C88B4ba954955746eDA6f475C26eeaC04614B, 267832328135071613547 [2.678e20], 267832333018493419789 [2.678e20])
│ │ └─ ← [Revert] ERC20InsufficientAllowance(0x062C88B4ba954955746eDA6f475C26eeaC04614B, 267832328135071613547 [2.678e20], 267832333018493419789 [2.678e20])
│ └─ ← [Revert] ERC20InsufficientAllowance(0x062C88B4ba954955746eDA6f475C26eeaC04614B, 267832328135071613547 [2.678e20], 267832333018493419789 [2.678e20])
└─ ← [Revert] Error != expected error: ERC20InsufficientAllowance(0x062C88B4ba954955746eDA6f475C26eeaC04614B, 267832328135071613547 [2.678e20], 267832333018493419789 [2.678e20]) != InsufficientBalance()

The root cause of this problem is the moment that the LendingPool's state is updated inside the function. It happens after fetching the user debt.

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
// Get the user's debt from the LendingPool.
@> uint256 scaledUserDebt = lendingPool.getUserDebt(userAddress);
...
// Approve the LendingPool to transfer the debt amount
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
// Update lending pool state before liquidation
@> lendingPool.updateState();
// Call finalizeLiquidation on LendingPool
@> lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}

The function consistently retrieves stale user debt data when the approved amount is less than the required amount. As demonstrated in the PoC, calling the function after _advanceInTime(1 seconds) shows that even 1 second of outdated state can cause a DoS in the liquidation process.

Impact

  • Protocol insolvency as it is not possible to liquidate undercollateralized positions.

Tools Used

Manual Review & Foundry

Recommendations

The fix: First update the lending pool state, then retrieve the user's debt value without applying additional scaling factors.

function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
+ // Update lending pool state before liquidation
+ lendingPool.updateState();
// Get the user's debt from the LendingPool.
- uint256 userDebt = lendingPool.getUserDebt(userAddress);
- uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
-
- if (scaledUserDebt == 0) revert InvalidAmount();
+ uint256 scaledUserDebt = lendingPool.getUserDebt(userAddress);
+ if (scaledUserDebt == 0) revert InvalidAmount();
- // Update lending pool state before liquidation
- lendingPool.updateState();

Run the PoC again. Now we see that liquidation works as expected:

[PASS] test_WhenCallingLiquidate_shouldRevert_dueToDoubleAmountAndInvalidState() (gas: 1366466)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 8.90ms (2.43ms CPU time)
Ran 1 test suite in 127.51ms (8.90ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

StabilityPool: liquidateBorrower should call lendingPool.updateState earlier, to ensure the updated usageIndex is used in calculating the scaledUserDebt

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.