Core Contracts

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

Borrowers can retain debt even after being liquidated.

Summary

StabilityPool's liquidation fails to ensure complete debt clearance after liquidation. When the StabilityPool liquidates a borrower, their debt can remain non-zero, creating "zombie debt" in the system.

The problem surfaces in the interaction between StabilityPool and LendingPool during liquidation. When the StabilityPool executes liquidateBorrower(), it successfully transfers collateral but fails to ensure the borrower's debt is completely cleared. This creates a scenario where a borrower's position shows as liquidated but still carries outstanding debt, while we expects that after a successful liquidation, getUserDebt(borrower) should return 0. However, the debt can persist after liquidation. This happens because:

  1. The StabilityPool's liquidateBorrower() function

  • Approves crvUSD transfer to LendingPool

  • Updates lending pool state

  • Calls finalizeLiquidation()

  1. The LendingPool's finalizeLiquidation()

  • Transfers NFTs to StabilityPool

  • Burns debt tokens

  • But doesn't guarantee complete debt clearance

Impact. This creates accounting inconsistencies where

  • Borrowers may retain debt obligations after losing collateral

  • System debt calculations become unreliable

  • The stability mechanism's effectiveness is compromised

Vulnerability Details

Unexpected Behavior when examining the interaction between StabilityPool and LendingPool during liquidations, I noticed something concerning. The StabilityPool successfully claims the NFT collateral but fails to ensure the borrower's debt is fully cleared. This creates what we call "zombie debt", debt that persists even after liquidation.

This is what happened, the StabilityPool initiates liquidation by calling liquidateBorrower(). It properly handles the crvUSD transfer and NFT acquisition, but it blindly trusts the LendingPool to clear the debt. This trust is misplaced because the LendingPool's finalizeLiquidation() lacks complete debt clearance verification.

Looking at the core contracts, we can see the flow: function liquidateBorrower

function liquidateBorrower(address userAddress) external {
_update(); // → Updates reward state and mints RAAC rewards
// ST1: DEBT CALCULATION
uint256 userDebt = lendingPool.getUserDebt(userAddress); // → Raw debt amount
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, // → Normalized debt with interest
lendingPool.getNormalizedDebt()); // → Gets current interest multiplier
if (userDebt == 0) revert InvalidAmount(); // → Basic validation
// ST2: BALANCE CHECK
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this)); // → StabilityPool's crvUSD balance
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();// → Ensures sufficient liquidity
// ST3: APPROVAL FLOW
bool approveSuccess = crvUSDToken.approve( // → Approves LendingPool to take crvUSD
address(lendingPool),
scaledUserDebt
);
if (!approveSuccess) revert ApprovalFailed();
// ST4: LIQUIDATION EXECUTION
lendingPool.updateState(); // → Updates lending pool indices
lendingPool.finalizeLiquidation(userAddress); // → Transfers NFT but doesn't verify debt clear
// → AUDIT VULNERABILITY: Missing debt clearance verification here
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
  • After finalizeLiquidation(), the function doesn't verify if the debt was actually cleared

  • The NFT collateral transfers but debt could remain

This creates a clear path showing how the liquidation flow can leave "zombie debt" in the system. means a borrower could lose their NFT collateral while still owing debt, a situation that should be impossible in a properly functioning liquidation system.

Impact

The StabilityPool assumes the LendingPool will handle debt clearance properly, but there's no verification. This creates a mismatch between collateral ownership and debt obligations, something that should be impossible in the protocol's economic model. This means that when the StabilityPool steps in to liquidate an underwater position, it's like taking the keys to a house while the mortgage mysteriously remains active.

Let's explore what's happening under the hood. The StabilityPool's liquidateBorrower() function interacts with the LendingPool through finalizeLiquidation(). This interaction should cleanly settle the position, transferring the NFT collateral and clearing all associated debt. However, the current implementation blindly trusts the LendingPool to handle debt clearance without verification.

This creates a critical state where the system's books don't balance. When a liquidation occurs, the protocol transfers valuable NFT collateral worth potentially millions in real estate value, but the corresponding debt obligation can remain in the system. This isn't just an accounting quirk, it fundamentally breaks the protocol's economic engine by creating "zombie debt" that shouldn't exist.

Tools Used

manual

Recommendations

  • After finalizeLiquidation(), the function doesn't verify if the debt was actually cleared

  • The NFT collateral transfers but debt could remain

  • Should add: require(lendingPool.getUserDebt(userAddress) == 0, "Debt not cleared");

This creates a clear path showing how the liquidation flow can leave "zombie debt" in the system.

// StabilityPool.sol
function liquidateBorrower(address userAddress) external {
_update();
// FL1: Calculate total debt with interest
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt,
lendingPool.getNormalizedDebt());
// FL2: Ensure sufficient crvUSD in StabilityPool
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
if (crvUSDBalance < scaledUserDebt) revert InsufficientBalance();
// FL3: Approve debt repayment
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
// FL4: Execute liquidation
lendingPool.updateState();
lendingPool.finalizeLiquidation(userAddress);
// VERY IMPT FIX: Verify debt clearance
require(lendingPool.getUserDebt(userAddress) == 0, "Debt not cleared");
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}

To ensures proper interaction between contracts:

  1. StabilityPool → LendingPool: Sends crvUSD to cover debt

  2. LendingPool → StabilityPool: Transfers NFT collateral

  3. StabilityPool: Verifies debt clearance through getUserDebt()

Updates

Lead Judging Commences

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

LendingPool::finalizeLiquidation passes normalized userDebt to DebtToken::burn which compares against scaled balance, causing incomplete debt clearance while taking all collateral

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

LendingPool::finalizeLiquidation passes normalized userDebt to DebtToken::burn which compares against scaled balance, causing incomplete debt clearance while taking all collateral

Support

FAQs

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

Give us feedback!