Description
The liquidation flow across StabilityPool, LendingPool, and DebtToken contracts contains critical mismatches in how scaled and non-scaled values are handled, potentially leading to incorrect debt settlements and asset transfers.
Affected code
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/StabilityPool/StabilityPool.sol#L440-L470
* @notice Liquidates a borrower's position.
* @dev This function can only be called by a manager or the owner when the contract is not paused.
* @param userAddress The address of the borrower to liquidate.
* @custom:throws InvalidAmount If the user's debt is zero.
* @custom:throws InsufficientBalance If the Stability Pool doesn't have enough crvUSD to cover the debt.
* @custom:throws ApprovalFailed If the approval of crvUSD transfer to LendingPool fails.
* @custom:emits BorrowerLiquidated when the liquidation is successful.
*/
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
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();
bool approveSuccess = crvUSDToken.approve(address(lendingPool), scaledUserDebt);
if (!approveSuccess) revert ApprovalFailed();
lendingPool.updateState();
lendingPool.finalizeLiquidation(userAddress);
emit BorrowerLiquidated(userAddress, scaledUserDebt);
}
https://github.com/Cyfrin/2025-02-raac/blob/89ccb062e2b175374d40d824263a4c0b601bcb7f/contracts/core/pools/LendingPool/LendingPool.sol#L496-L540
* @notice Allows the Stability Pool to finalize the liquidation after the grace period has expired
* @param userAddress The address of the user being liquidated
*/
function finalizeLiquidation(address userAddress) external nonReentrant onlyStabilityPool {
if (!isUnderLiquidation[userAddress]) revert NotUnderLiquidation();
ReserveLibrary.updateReserveState(reserve, rateData);
if (block.timestamp <= liquidationStartTime[userAddress] + liquidationGracePeriod) {
revert GracePeriodNotExpired();
}
UserData storage user = userData[userAddress];
uint256 userDebt = user.scaledDebtBalance.rayMul(reserve.usageIndex);
isUnderLiquidation[userAddress] = false;
liquidationStartTime[userAddress] = 0;
for (uint256 i = 0; i < user.nftTokenIds.length; i++) {
uint256 tokenId = user.nftTokenIds[i];
user.depositedNFTs[tokenId] = false;
raacNFT.transferFrom(address(this), stabilityPool, tokenId);
}
delete user.nftTokenIds;
(uint256 amountScaled, uint256 newTotalSupply, uint256 amountBurned, uint256 balanceIncrease) = IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
IERC20(reserve.reserveAssetAddress).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
user.scaledDebtBalance -= amountBurned;
reserve.totalUsage = newTotalSupply;
ReserveLibrary.updateInterestRatesAndLiquidity(reserve, rateData, amountScaled, 0);
emit LiquidationFinalized(stabilityPool, userAddress, userDebt, getUserCollateralValue(userAddress));
}
Vulnerability details
During the analysis of the liquidation flow, several significant issues were identified in how values are handled between contracts. The primary concern lies in the LendingPool contract where amountScaled returned from the burn() function is actually the non-scaled amount, creating a fundamental mismatch in value handling. Additionally, amountBurned is incorrectly used to reduce user.scaledDebtBalance, despite representing the scaled amount. The token transfer mechanism compounds these issues by using amountScaled, which is not the correct value for the transfer operation.
These inconsistencies create a cascade of potential problems throughout the system. The immediate impact includes incorrect debt settlements during liquidations and potential loss of funds due to incorrect transfer amounts. On a broader scale, these mismatches can lead to system-wide accounting errors that accumulate over time. The most concerning outcome is the possibility of protocol insolvency due to persistent debt and collateral mismatches that could arise from these calculation errors.
Tools Used
Manual Review
Recommended Mitigation Steps
First, the finalizeLiquidation function should be modified to properly handle scaled and non-scaled values throughout its execution. The function should explicitly use the correct scaled amount for transfers and update the user's scaled debt balance appropriately.
function finalizeLiquidation(address userAddress) {
(uint256 amount, uint256 newTotalSupply, uint256 amountScaled, uint256 balanceIncrease) =
IDebtToken(reserve.reserveDebtTokenAddress).burn(userAddress, userDebt, reserve.usageIndex);
IERC20(...).safeTransferFrom(msg.sender, reserve.reserveRTokenAddress, amountScaled);
user.scaledDebtBalance -= amountScaled;
}