Balancer BatchSwap Mispricing
Exploit Transactions
Victim Addresses
0xba12222222228d8ba445958a75a0704d566bf2c8Ethereum0xdacf5fa19b1f720111609043ac67a9818262850cEthereum0x93d199263632a4ef4bb438f1feb99e57b4b5f0bdEthereumLoss Breakdown
Similar Incidents
Balancer bb-a-USD stale-rate exploit
38%BatchSwap Counterpart Reentrancy
35%TRU reserve mispricing attack drains WBNB from pool
32%Sturdy LP Oracle Manipulation
30%Flashstake LP Share Inflation
30%MahaLend Liquidity Index Inflation
29%Root Cause Analysis
Balancer BatchSwap Mispricing
1. Incident Overview TL;DR
An unprivileged adversary executed a two-transaction exploit against Balancer on Ethereum mainnet at block 23717397. The first transaction, 0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742, deployed attacker helpers and ran large Vault.batchSwap sequences against ComposableStablePool contracts 0xdacf5fa19b1f720111609043ac67a9818262850c and 0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd. The second transaction, 0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569, withdrew the resulting Vault internal balances to adversary recipient 0xaa760d53541d8390074c61defeaba314675b8e3f.
The root cause was Balancer ComposableStablePool using a virtual-supply term derived from live Vault BPT balances while processing BPT join-like and exit-like swaps inside a single Vault-managed batch swap. That let the attacker alternate mint and redeem legs against inconsistent pool-state snapshots and extract value as BPT, osToken, and wstETH.
2. Key Background
ComposableStablePool pre-mints its pool token (BPT) and keeps the preminted BPT in the Balancer Vault. Effective circulating supply is therefore not just totalSupply(): the pool computes a virtual supply by subtracting the Vault-held BPT balance.
BPT swaps in ComposableStablePool are not plain token swaps. They route through _swapWithBpt(), which internally models the operation as a join or exit, computes a pre-join/exit invariant, and then prices the leg with StableMath.
Balancer's own source comments warn that getRate() and getActualSupply() are unsafe in Vault context because pool accounting and Vault balances may diverge during a join or exit. That warning is directly relevant here because the vulnerable path also derives supply from Vault-context balances.
The attacker used a standard EOA, helper contract 0x54b53503c0e2173df29f8da735fbd45ee8aba30d, and a child search helper 0x679b362b9f38be63fbd4a499413141a997eb381e. The helper contracts were attacker tools, not privileged protocol components.
3. Vulnerability Analysis & Root Cause Summary
The exploit class is an on-chain accounting attack against Balancer ComposableStablePool BPT swap logic. In _swapWithBpt(), the pool calls _beforeJoinExit(registeredBalances) before pricing the BPT leg. _beforeJoinExit() ultimately depends on _dropBptItemFromBalances(), which computes virtual supply as totalSupply() - registeredBalances[getBptIndex()]. Inside Vault.batchSwap, that registeredBalances input reflects live Vault-context balances rather than a stable pool-state snapshot for the entire alternating path. The attacker exploited that mismatch by interleaving BPT mint-like and redeem-like legs within one transaction, causing join-side and exit-side pricing to use inconsistent supply and invariant inputs. The result was disproportionate BPT issuance and underlier extraction, followed by withdrawal of the created internal balances.
4. Detailed Root Cause Analysis
The critical victim-side code path is:
function _swapWithBpt(...) private returns (uint256) {
...
(
uint256 preJoinExitSupply,
uint256[] memory balances,
uint256 currentAmp,
uint256 preJoinExitInvariant
) = _beforeJoinExit(registeredBalances);
...
}
function _beforeJoinExit(uint256[] memory registeredBalances)
internal
returns (uint256, uint256[] memory, uint256, uint256)
{
...
(
uint256 preJoinExitSupply,
uint256[] memory balances,
uint256 oldAmpPreJoinExitInvariant
) = _payProtocolFeesBeforeJoinExit(registeredBalances, lastJoinExitAmp, lastPostJoinExitInvariant);
...
}
function _dropBptItemFromBalances(uint256[] memory registeredBalances)
internal
view
returns (uint256, uint256[] memory)
{
return (_getVirtualSupply(registeredBalances[getBptIndex()]), _dropBptItem(registeredBalances));
}
_getVirtualSupply() uses the Vault-provided BPT balance to derive effective supply. That is acceptable only if the balances correspond to a stable state snapshot. In this incident they did not: the attacker deliberately used a long Vault.batchSwap with alternating BPT and underlier legs against the same pool, so the BPT balance observed by one leg was transient with respect to the full path.
The tx1 trace shows the economic distortion directly. For pool 0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd, the attacker obtained 10000 BPT for only 2 wei of wstETH, then 3418009626758926269710 BPT for only 10997540304013142098 wei of wstETH, and then redeemed that BPT back out through subsequent legs:
emit Swap(... tokenIn: wstETH, tokenOut: pool1 BPT, amountIn: 2, amountOut: 10000)
emit Swap(... tokenIn: WETH, tokenOut: pool1 BPT, amountIn: 562519, amountOut: 10000000)
emit Swap(... tokenIn: wstETH, tokenOut: pool1 BPT, amountIn: 10997540304013142098, amountOut: 3418009626758926269710)
emit Swap(... tokenIn: WETH, tokenOut: pool1 BPT, amountIn: 13179032742041136873, amountOut: 3418009626758926269710)
Those outputs are not consistent with honest pricing from a single coherent pool state. The invariant that should have held is: during a BPT join or exit style swap, the supply term and invariant used for pricing must match the same pool-state snapshot as the balances used for that leg. The reported breakpoint in _swapWithBpt() -> _beforeJoinExit() -> _dropBptItemFromBalances() -> _getVirtualSupply() is therefore correct.
Balancer's own source comments reinforce the same hazard. getRate() and getActualSupply() explicitly state that Vault-context reads are unsafe because pool state may be out of sync with Vault state during joins or exits. The exploited BPT swap path relies on the same unsafe class of input.
5. Adversary Flow Analysis
The adversary cluster is evidence-backed:
- EOA
0x506d1f9efe24f0d47853adca907eb8d89ae03207sent both exploit transactions. - Contract
0x54b53503c0e2173df29f8da735fbd45ee8aba30dwas created by that EOA in tx1 and executed the exploit logic. - Contract
0x679b362b9f38be63fbd4a499413141a997eb381ewas created during tx1 and its disassembly shows attacker-controlled logic gated bytx.origin. - EOA
0xaa760d53541d8390074c61defeaba314675b8e3freceived the realized assets in tx2 and has code size zero in the collected artifacts.
The execution flow was:
- Tx1 deployed the helper/orchestration contracts and approved Balancer Vault spending for the relevant tokens.
- Tx1 ran one large exploit
batchSwapagainst pool0xdacf...850cand another against pool0x93d1...f0bd, alternating BPT and underlier legs to force mispriced mint and redeem behavior. - The helper accumulated positive internal balances in the Vault rather than immediately withdrawing everything.
- Tx2 queried
getInternalBalanceand then calledmanageUserBalanceto withdraw the internal balances to the recipient EOA.
The tx2 trace confirms the realization step:
Vault::getInternalBalance(... [WETH, pool0 BPT, osToken]) -> [6587440315017497938362, 44154666355785411629, 6851122954235076557965]
Vault::manageUserBalance(... recipient: 0xAa760D53541d8390074c61DEFeaba314675b8e3f)
Vault::getInternalBalance(... [wstETH, pool1 BPT, WETH]) -> [4259843451780587743322, 20413668455251157822, 0]
Vault::manageUserBalance(... recipient: 0xAa760D53541d8390074c61DEFeaba314675b8e3f)
This sequence is ACT. It used public chain state, public protocol contracts, and standard EIP-1559 transactions. No admin privilege, private key compromise, or private orderflow access is required by the collected evidence.
6. Impact & Losses
The measurable asset extraction evidenced by tx2 balance diffs is:
44154666355785411629of pool BPT0xdacf5fa19b1f720111609043ac67a9818262850c6851122954235076557965ofosToken0xf1c9acdc66974dfb6decb12aa385b9cd01190e384259843451780587743322ofwstETH0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca020413668455251157822of pool BPT0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd
The Vault-side holders for those same assets lost the corresponding amounts in the collected tx2 balance diffs. The incident also materially distorted pool state: the trace shows both pools ending with sharply degraded balances and rates after the exploit path.
7. References
- Seed tx1:
0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742 - Seed tx2:
0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569 - Balancer Vault:
0xba12222222228d8ba445958a75a0704d566bf2c8 - Victim pool 0:
0xdacf5fa19b1f720111609043ac67a9818262850c - Victim pool 1:
0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd - Victim source files:
ComposableStablePool.sol,ComposableStablePoolStorage.sol - Supporting artifacts used in validation: tx metadata, full traces, tx receipt, tx2 balance diffs, helper creation metadata, and attacker helper disassembly under
/workspace/session/artifacts/collector/