Summary
In the StabilityPool contract we take the user debt that is already scaled accordingly to the borrow rate(usageIndex of the LendingPool), but then we go again and multiply that value by the usage index once again. This can cause a InsufficientBalance() revert if the contract has enough balance to repay the original user balance but not enough to repay the "fake" double upscaled user debt.
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
+ lendingPool.updateState();
uint256 userDebt = lendingPool.getUserDebt(userAddress);
uint256 scaledUserDebt = WadRayMath.rayMul(userDebt, lendingPool.getNormalizedDebt());
if (userDebt == 0) revert InvalidAmount();
uint256 crvUSDBalance = crvUSDToken.balanceOf(address(this));
console.log("User scaled debt to be approved to be spent by Stability Pool:", scaledUserDebt);
console.log("crvUSD balance of Stability Pool is:", crvUSDBalance);
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);
}
Vulnerability Details
The described scenarios would occur whenever the StabilityPool contract has enough balance to repay an user debt but the incorrect double upscaling of the debt of the user would cause the revert on line 17 above to trigger if the contract has just enough balance to repay only the correct user debt. This is a big problem because the user position might go into an insolvent state before we have "enough balance" to cover the incorrect debt and to to liquidated it, this will result in loses to the protocol.
Impact
Users that have a big enough position, are under the liquidation and their grace period has passed may not be liqudated by the StabilityPool as their debt is incorrectly scaled to an even bigger value in the liquidateBorrower function call. In this case the liquidation will fail if the Stability pool dose not have enough balance to cover the incorrect higher value.
Tools Used
PoC
Place the test bellow in the StabilityPool.test.js file, in the describe("Core Functionality") group of tests.
it("POC Stability Pool incorrect user debt calculation when approving funds for liquidation", async function () {
const mintAmount = ethers.parseEther("1000");
await crvusd.mint(user1.address, mintAmount);
await crvusd.mint(stabilityPool.target, ethers.parseEther("80.05"));
await raacHousePrices.setOracle(owner.address);
await raacHousePrices.setHousePrice(1, ethers.parseEther("100"));
await ethers.provider.send("evm_mine", []);
const tokenId = 1;
const amountToPay = ethers.parseEther("100");
await crvusd.connect(user1).approve(raacNFT.target, amountToPay);
await raacNFT.connect(user1).mint(tokenId, amountToPay);
await raacNFT.connect(user1).approve(lendingPool.target, tokenId);
await lendingPool.connect(user1).depositNFT(tokenId);
const borrowAmount = ethers.parseEther("80");
await lendingPool.connect(user1).borrow(borrowAmount);
await raacHousePrices.setHousePrice(1, ethers.parseEther("90"));
console.log("User 1 health factor is: ", await lendingPool.calculateHealthFactor(user1.address));
await lendingPool.connect(user2).initiateLiquidation(user1.address);
console.log("Simulating 128hours have passed(this is the grace period + additional time to scale the borrow debt)....")
await ethers.provider.send("evm_increaseTime", [128 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
await lendingPool.updateState();
console.log("User 1 debt is: ", await lendingPool.getUserDebt(user1.address));
await expect(stabilityPool.liquidateBorrower(user1.address))
.to.be.revertedWithCustomError(stabilityPool, "InsufficientBalance");
});
Console logs output:
User 1 health factor is: 900000000000000000n
Simulating 128hours have passed(this is the grace period + additional time to scale the borrow debt)....
User 1 debt is: 80032058339777226533n
User scaled debt to be approved to be spent by Stability Pool: 80064129526268818968
crvUSD balance of Stability Pool is: 80050000000000000000
Recommendations
Remove the line that scales the debt of the user in the liquidateBorrower as that value is already scaled by the normolized debt in the lendingPool.getUserDebt(userAddress) call:
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
lendingPool.updateState();
// Get the user's debt from the LendingPool.
uint256 userDebt = lendingPool.getUserDebt(userAddress);
//@Audit HIGH we have already scaled the debt now we are scaling it for a second time
- 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);
}