Core Contracts

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

Double upscale of user debt when calling liquidateBorrower may causes incorrect InsufficientBalance revert

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();
// 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));
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();
// 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);
}

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

  • Manual Review

  • Unit test

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 () {
//We approve additional amount of asset to users1 in order to be able to buy an NFT
const mintAmount = ethers.parseEther("1000");
await crvusd.mint(user1.address, mintAmount);
//We mint just enough curvUSD assets to the StabilityPool to repay the debt of user1
await crvusd.mint(stabilityPool.target, ethers.parseEther("80.05"));
//We set a price for the NFT and buy it as user1
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);
// User1 deposits NFT into Lending Pool and borrows 80 worth of ETH form the LendingPool
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);
//NFT price degrades and user1 is now could be liquidated
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)....")
// Advance time beyond grace period (72 hours)
await ethers.provider.send("evm_increaseTime", [128 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
//We need to firstly update the reserve of the LendingPool to the latest state, explicit call is needed due to a different bug
await lendingPool.updateState();
//User1 has not repayed his debt so we can finilize the liquidation
console.log("User 1 debt is: ", await lendingPool.getUserDebt(user1.address));
//StabilityPool attempts to liquidate user1 and fails because the incorrect debt calculate is larger than the reserves we have in the Stability Pool
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);
}
Updates

Lead Judging Commences

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

Give us feedback!