Summary
User could not be liquidated because debt is calulated on an outdated state of the reserve in the StabilityPoolwhen we call the liquidateBorrower function, we aprove the amount to be spend and after that in the LendingPool when the state is updated we get higer debt that could not be payed as a smaller value for the LendingPool to spend has been approved.
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));
console.log("User debt amount 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 vunerability root cause is that the state of the reserve of the LendingPool is outdated when we calculate and approve the amount of debt to be payed by the StabilityPool.
Impact
Users are not liquidatable unless someone updates the state of the reserve in the LendingPool.
Tools Used
PoC
Place this test in the StabilityPool.test.js file as part of the describe("Core Functionality") test suite group.
it("POC Stability Pool insufficent balance approval to cover liquidation debt", async function () {
const mintAmount = ethers.parseEther("1000");
await crvusd.mint(user1.address, mintAmount);
await crvusd.mint(stabilityPool.target, ethers.parseEther("81"));
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 72hours have passed(this is the grace period)....")
await ethers.provider.send("evm_increaseTime", [72 * 60 * 60 + 1]);
await ethers.provider.send("evm_mine");
console.log("User 1 debt is: ", await lendingPool.getUserDebt(user1.address));
const actualUserDebt = 80018031266118120000n;
const calculateUserDebt = ethers.parseEther("80");
await expect(stabilityPool.liquidateBorrower(user1.address))
.to.be.revertedWithCustomError(crvusd, "ERC20InsufficientAllowance")
.withArgs(lendingPool, calculateUserDebt, (actualAmount) => {
expect(actualAmount).to.be.closeTo(actualUserDebt, ethers.parseEther("0.0000001"));
return true;
});
});
Recommendations
Move the lendingPool.updateState() call to the top of the liquidateBorrower function so that the debt is calculated against an up to date reserve state:
function liquidateBorrower(address userAddress) external onlyManagerOrOwner nonReentrant whenNotPaused {
_update();
+ lendingPool.updateState();
// 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));
console.log("User debt amount 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);
}