Core Contracts

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

Incorrect Debt Scaling in `StabilityPool.liquidateBorrower` Creates Liquidation Failures and Residual Allowance Risk

Summary

The StabilityPool contract contains a critical calculation error in its liquidation process StabilityPool.liquidateBorrower where debt values are improperly scaled using multiplication instead of division. This leads to:

  1. Over-inflated Debt Checks: Causes valid liquidations to fail unnecessarily when pool balances are sufficient for actual debt but not the inflated amount

  2. Dangerous Token Approvals: Leaves residual allowances that could be exploited in future protocol upgrades

The vulnerability stems from mismatched scaling logic compared to the system's DebtToken._update implementation, creating protocol-level risks despite no immediate fund loss in the current version. Fixing this requires correcting the scaling operation and implementing safe allowance practices.

Vulnerability Details

The liquidation process in StabilityPool.liquidateBorrower contains an inverted scaling calculation that creates protocol risks. The vulnerability exists in the debt scaling logic at StabilityPool.sol#L453 where:

  1. Incorrect Scaling Operation: The code uses rayMul with the normalized debt index instead of rayDiv, creating an over-inflated debt value

  2. Mismatch with DebtToken Logic: This contradicts the established scaling pattern seen in DebtToken._update (DebtToken.sol#L256-L264) which correctly uses rayDiv for debt scaling

The core issue stems from:

  • getUserDebt() returns actual debt (scaledDebt × index)

  • getNormalizedDebt() returns the current index

  • Using multiplication instead of division creates a squared index multiplier (debt × index²)

contract StabilityPool is IStabilityPool, Initializable, ReentrancyGuard, OwnableUpgradeable, PausableUpgradeable {
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 (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
// 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);
}
}
contract LendingPool is ILendingPool, Ownable, ReentrancyGuard, ERC721Holder, Pausable {
function getUserDebt(address userAddress) public view returns (uint256) {
UserData storage user = userData[userAddress];
@> return user.scaledDebtBalance.rayMul(reserve.usageIndex);
}
function getNormalizedDebt() external view returns (uint256) {
@> return reserve.usageIndex;
}
}
contract DebtToken is ERC20, ERC20Permit, IDebtToken, Ownable {
function _update(address from, address to, uint256 amount) internal virtual override {
if (from != address(0) && to != address(0)) {
revert TransfersNotAllowed(); // Only allow minting and burning
}
@> uint256 scaledAmount = amount.rayDiv(ILendingPool(_reservePool).getNormalizedDebt());
super._update(from, to, scaledAmount);
emit Transfer(from, to, amount);
}
}

This manifests two critical effects:

  1. Insufficient Balance False Positives: The inflated scaledUserDebt causes unnecessary reverts when pool balance covers actual debt but not the inflated value

  2. Residual Allowance Risk: Over-approval leaves dangerous residual allowances that could be exploited by future protocol upgrades or added functionality

While the current LendingPool implementation correctly transfers only the actual debt amount, the residual approval remains as protocol-level technical debt that violates security best practices for token approvals.

Impact

This vulnerability creates two primary negative impacts on protocol operations:

  1. Operational Failure Risk
    Legitimate liquidations will fail when:
    actualDebt ≤ poolBalance < inflatedDebt
    This leaves undercollateralized positions active, threatening protocol solvency during market downturns.

  2. Approval Poisoning
    Persistent residual allowances create:

    • Technical debt requiring future cleanup

    • Potential exploit vectors for any new functionality interacting with StabilityPool's crvUSD approvals

    • Violation of least-privilege principle for token allowances

Severity Classification: Medium
While no immediate fund loss occurs in the current implementation, the combination of failed liquidations and dangerous approval patterns creates systemic risk that violates security best practices and threatens long-term protocol health.

Tools Used

Manual Review

Recommendations

  1. Correct Scaling Calculation
    Modify the debt scaling logic in StabilityPool.liquidateBorrower (StabilityPool.sol#L453) to:

    - uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
    + uint256 scaledUserDebt = WadRayMath.rayDiv(userDebt, lendingPool.getNormalizedDebt());

    This aligns with the established pattern in DebtToken._update and properly converts actual debt to scaled terms.

  2. Safe Allowance Management
    Implement allowance cleanup after liquidation:

    crvUSDToken.approve(address(lendingPool), 0); // Reset approval

    This follows the best practice of resetting allowances to prevent residual permissions.

These changes maintain protocol integrity while eliminating both the operational failure risk and approval poisoning vectors.

Updates

Lead Judging Commences

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