BalancerV2 Rounding Exploit - Post-Mortem
Balancer V2, a decentralized automated market maker (AMM) protocol, an attacker exploited a precision rounding vulnerability in multiple pools. The attack occurred on Nov 3, 2025.
The attacker leveraged a rounding manipulation in BPT (Balancer Pool Token) rate calculations when pool liquidity approached zero. By executing sequences of GIVEN_OUT swaps with toInternalBalance: true, the attacker systematically drained multiple pools across multiple networks, accumulating funds in the Vault's internal balance before withdrawing everything via manageUserBalance() calls.
The exploit abused precision loss in the Vault's swap() function when using SwapKind.GIVEN_OUT, where the attacker specifies exact output amounts and the Vault calculates required input (BPT). At minimal liquidity, rounding errors favor the attacker, allowing extraction of more tokens than BPT paid justifies.
Rounding Manipulation in GIVEN_OUT Swaps
Balancer V2 Vault supports two swap modes:
GIVEN_IN: User specifies input, Vault calculates outputGIVEN_OUT: User specifies output, Vault calculates input ← exploited
Definitions:
GIVEN_IN("Given In"): the caller specifies the exact amount of the input token, and the pool calculates the corresponding output amount.GIVEN_OUT("Given Out"): the caller specifies the desired output amount, and the pool computes the required input amount.
Typically, a batchSwap() consists of multiple token-to-token swaps executed via the onSwap() function. The following outlines the execution path when a SwapRequest is assigned a GIVEN_OUT swap type (note that ComposableStablePool inherits from BaseGeneralPool):
Each swap drains reserves → subsequent swaps have worse precision → compounding effect.
Root Cause: Incorrect Rounding Direction
The underlying issue arises from the rounding-down operation performed during upscaling in the BaseGeneralPool._swapGivenOut() function. In particular, _swapGivenOut() incorrectly rounds down swapRequest.amount through the _upscale() function. The resulting rounded value is subsequently used as amountOut when calculating amountIn via _onSwapGivenOut(). This behavior contradicts the standard practice that rounding should be applied in a manner that benefits the protocol.
|
|
For a given pool (e.g., wstETH/rETH/cbETH), the computed amountIn underestimates the actual required input. This allows a user to exchange a smaller quantity of one underlying asset (e.g., wstETH) for another (e.g., cbETH), thereby decreasing the invariant D as a result of reduced effective liquidity. Consequently, the price of the corresponding BPT (wstETH/rETH/cbETH) becomes deflated, since BPT price = D / totalSupply.
Attack Mechanics
Step 1:
The attacker swaps BPT for underlying assets to precisely adjust the balance of one token (cbETH) to the edge of a rounding boundary (amount = 9). This sets up the conditions for precision loss in the next step.
Step 2: Rounding Manipulation
The attacker then swaps between another underlying (wstETH) and cbETH using a crafted amount (= 8). Due to rounding down when scaling token amounts, the computed Δx becomes slightly smaller (8.918 to 8), leading to an underestimated Δy and thus a smaller invariant (D from Curve's StableSwap model). Since BPT price = D / totalSupply, the BPT price becomes artificially deflated.
Using the best candidates, SC1 constructed one long batchSwap. The steps alternated indices in a 4-leg block, indicating swaps in circular directions. Input amounts were chosen to keep balances near rounding thresholds so that repeated down-rounding in Stable math underestimated D within the call.
Step 3: Reverse Swap & Profit
The attacker reverse-swaps the underlying assets back into BPT, restoring balance while profiting from the deflated BPT price.
Stage 1: Initial BPT Drain (Reduce Liquidity)
The attacker executes multiple GIVEN_OUT swaps to drain underlying tokens from the pool:
|
|
Purpose: Reduce pool reserves to approach rounding boundaries. Each swap:
- Extracts large amounts of WETH/osETH/other tokens
- Pays minimal BPT due to rounding errors
- Stores extracted funds in Vault's internal balance (invisible to external queries)
- Creates cascading precision loss for subsequent swaps
Result after Stage 1:
osETH/WETH Pool Example:
Initial: WETH: 4,922,356,564,867,078,856,521 wei (4,922 ETH)
osETH: 6,851,581,236,039,298,760,900 wei (6,851 ETH)
After: WETH: ~67,000 wei (nearly zero)
osETH: ~67,000 wei (nearly zero)
Stage 2: Micro-Swap Manipulation (Rounding Boundary Setup)
Execute precision swaps between underlying tokens to hit exact rounding boundaries:
|
|
Rounding Mechanics:
Expected calculation: Δx = 8.918 tokens
Actual after rounding: Δx = 8 tokens (rounds down)
Result: Invariant D decreases artificially
BPT price = D / totalSupply becomes deflated
Stage 3: Reverse Swap & Profit Extraction
Swap extracted tokens back to BPT at deflated prices:
|
|
Final Withdrawal:
|
|
Pools Execution Flow
Ethereum
1. osETH/WETH Pool
Pool ID: 0xdacf5fa19b1f720111609043ac67a9818262850c000000000000000000000635
Contract: 0xDACf5Fa19b1f720111609043ac67A9818262850c
Block: 23717101
Initial State:
WETH (index 0): 4,922,356,564,867,078,856,521 wei (4,922.36 ETH)
BPT (index 1): 2,596,148,429,267,421,974,637,745,197,985,291
osETH (index 2): 6,851,581,236,039,298,760,900 wei (6,851.58 ETH)
Final State:
WETH: ~0 wei
BPT: ~2,596,148,429,267,421,974,637,745,197,985,291
osETH: ~0 wei
Extraction:
WETH extracted: 4,623 ETH
osETH extracted: 6,851 ETH
Total: ~11,474 ETH
2. wstETH/WETH Pool
Pool ID: 0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd0000000000000000000005c2
Contract: 0x93d199263632a4EF4Bb438F1feB99e57b4b5f0BD
Initial State:
wstETH (index 0): 4,270,841,022,451,395,518,160 wei (4,270.84 ETH)
BPT (index 1): 2,596,148,429,267,825,815,119,599,282,622,812
WETH (index 2): 1,977,057,709,608,602,150,017 wei (1,977.06 ETH)
Final State:
wstETH: ~0 wei
BPT: ~2,596,148,429,267,825,815,119,599,282,622,812
WETH: ~0 wei
Extraction:
wstETH extracted: 4,259 ETH
WETH extracted: 1,963 ETH
Total: ~6,222 ETH
Type: ComposableStablePool (BPT at index 1)
3. rsETH/WETH Pool
Pool ID: 0x58aadfb1afac0ad7fca1148f3cde6aedf5236b6d00000000000000000000067f
Contract: 0x58AAdFB1Afac0ad7fca1148f3cdE6aEDF5236B6D
Initial State:
BPT: 2,596,148,429,267,450,795,110,561,571,390,003
rsETH: 1,192,977,132,125,824,431,719 wei (1,192.98 ETH)
WETH: 891,081,044,240,689,768,290 wei (891.08 ETH)
Final State:
BPT: 2,596,148,429,269,518,772,219,140,401,518,800
rsETH: 186,036,293 wei (0.00000019 ETH)
WETH: 1,265,989,959,414 wei (0.0000013 ETH)
Pool Deltas:
BPT delta: +2,067,977,108,578,830,128,797
rsETH delta: -1,192,977,132,125,638,395,426 wei
WETH delta: -891,081,042,974,699,808,876 wei
Extraction:
rsETH extracted: 1,192 ETH
WETH extracted: 891 ETH
Total: ~2,083 ETH
Type: WeightedPool
4. weETH/ezETH/rswETH Pool (Triple)
Pool ID: 0x848a5564158d84b8a8fb68ab5d004fae11619a5400000000000000000000066a
Contract: 0x848a5564158d84b8A8fb68ab5D004Fae11619A54
Initial State:
BPT: 2,596,148,429,268,072,998,691,221,627,584,336
ezETH: 34 ETH
weETH: 32 ETH
rswETH: 86 ETH
Final State:
BPT: 2,596,148,429,268,140,032,469,895,427,901,537
ezETH: 2 ETH
weETH: 3 ETH
rswETH: 85 ETH
Extraction:
ezETH extracted: 31 ETH
weETH extracted: 28 ETH
rswETH extracted: 0 ETH
Total: ~59 ETH
5. weETH/rETH Pool
Pool ID: 0x05ff47afada98a98982113758878f9a8b9fdda0a000000000000000000000645
Contract: 0x05ff47AFADa98a98982113758878F9A8B9FddA0a
Initial State:
BPT: 2,596,148,429,267,578,197,359,306,107,962,432
rETH: 707 ETH
weETH: 496 ETH
Final State:
BPT: 2,596,148,429,268,872,713,054,242,274,930,440
rETH: 3 ETH
weETH: 0 ETH
Extraction:
rETH extracted: 703 ETH
weETH extracted: 495 ETH
Total: ~1,198 ETH
6-7. wstETH/rETH/sfrxETH Pools (Quad #1 & #2)
Pool #1: 0x5aee1e99fe86960377de9f88689616916d5dcabe000000000000000000000467
Contract: 0x5aEe1e99fE86960377DE9f88689616916D5DcaBe
Initial State:
BPT: 2,596,148,429,266,373,209,145,083,915,007,527
wstETH: 0 ETH
sfrxETH: 4 ETH
rETH: 0 ETH
Final State:
BPT: 2,596,148,429,266,379,970,928,315,936,291,762
wstETH: 0 ETH
sfrxETH: 0 ETH
rETH: 0 ETH
Extraction:
wstETH extracted: 0 ETH
sfrxETH extracted: 4 ETH
rETH extracted: 0 ETH
Total: ~4 ETH
------------------------------------------------------------------------
Pool #2: 0x42ed016f826165c2e5976fe5bc3df540c5ad0af700000000000000000000058b
Contract: 0x42ED016F826165C2e5976fe5bC3df540C5aD0Af7
Initial State:
BPT: 2,596,148,429,267,580,463,942,935,196,878,193
wstETH: 203 ETH
sfrxETH: 2,265 ETH
rETH: 232 ETH
Final State:
BPT: 2,596,148,429,270,382,247,078,373,301,460,520
wstETH: 11 ETH
sfrxETH: 117 ETH
rETH: 11 ETH
Extraction:
wstETH extracted: 191 ETH
sfrxETH extracted: 2,148 ETH
rETH extracted: 220 ETH
Total: ~2,559 ETH
8. ezETH/WETH Pool
Pool ID: 0x596192bb6e41802428ac943d2f1476c1af25cc0e000000000000000000000659
Contract: 0x596192bB6e41802428Ac943D2f1476C1Af25CC0E
Initial State:
BPT: 2,596,148,429,268,001,550,290,005,844,565,929
ezETH: 756 ETH
WETH: 444 ETH
Final State:
BPT: 2,596,148,429,269,196,196,829,326,878,149,524
ezETH: 5 ETH
WETH: 2 ETH
Extraction:
ezETH extracted: 751 ETH
WETH extracted: 442 ETH
Pool Total: ~1,193 ETH
Ethereum Subtotal: ~24,792 ETH
Arbitrum
wstETH/rETH/cbETH Pool
Pool ID: 0x4a2f6ae7f3e5d715689530873ec35593dc28951b000000000000000000000481
Contract: 0x4a2F6Ae7F3e5D715689530873ec35593Dc28951B
Block: 308179309
Initial State:
cbETH: 385,331,897,945,415,101,145 wei (385.33 cbETH)
wstETH: 36,378,350,238,858,588,950 wei (36.38 wstETH)
rETH: 41,301,528,246,890,260,702 wei (41.30 rETH)
Final State:
cbETH: 499,964,114,717,559,002,777 wei (499.96 cbETH)
wstETH: 500,001,531,748,221,662,222 wei (500.00 wstETH)
rETH: 500,000,000,100,000,000,000 wei (500.00 rETH)
Extraction: ~462 ETH total
Type: ComposableStablePool (BPT at index 1)
Arbitrum Subtotal: ~462 ETH
Base
1. rETH/WETH Pool
Pool ID: 0xc771c1a5905420daec317b154eb13e4198ba97d0000000000000000000000023
Contract: 0xC771c1a5905420DAEc317b154EB13e4198BA97D0
Block: 37683327
Transaction: 0xe9245fb124c3a6ff6a0e39c6d0db02b74b3a3d805f6bf016f4b9ac56cbfb73ae
Extraction: ~41 ETH
Final residual: ~17 WETH + ~24 rETH
2. weETH/wETH Pool
Pool ID: 0xab99a3e856deb448ed99713dfce62f937e2d4d74000000000000000000000118
Contract: 0xaB99a3e856dEb448eD99713dfce62F937E2d4D74
Block: 37683327
Transaction: 0x927c9e6d9fc26b2ee13b88f553701a4e7514f8220d34e6517c634ddd135cd874
Initial: 6 weETH + 5 WETH
Extraction: <1 ETH
3. cbETH/WETH Pool
Pool ID: 0xfb4c2e6e6e27b5b4a07a36360c89ede29bb3c9b6000000000000000000000026
Contract: 0xFb4C2E6E6e27B5b4a07a36360C89EDE29bB3c9B6
Block: 37683370
Transaction: 0xd61f26bd435b31f781165a522fc78a040f864eafc74e07f86314ca265d96287d
Initial: 10 cbETH + 0.5 WETH
Extraction: <1 ETH
Base Subtotal: ~41 ETH
Actual PoC Extraction Results
(These are the current values in relation to the pool and our work, they may change!)
Ethereum (8 pools): ~24,792 ETH
Arbitrum (1 pool): ~462 ETH
Base (3 pools): ~41 ETH
──────────────────────
TOTAL: ~25,295 ETH
Attack Timeline
Ethereum Block 23717101-23717404
├─ 23717101: osETH/WETH pool drainage begins
├─ 23717102-23717150: rsETH/WETH pool attacked
├─ 23717151-23717200: wstETH/WETH pool drained
├─ 23717201-23717300: Multi-token pools exploited
└─ 23717404: Final withdrawal via manageUserBalance()
Arbitrum Block 308179309
└─ wstETH/rETH/cbETH pool exploited (462 ETH)
Base Blocks 37683327-37683376
├─ rETH/WETH pool: 106 swaps, 41 ETH
├─ weETH/wETH pool: 86 swaps, <1 ETH
└─ cbETH/WETH pool: 83 swaps, <1 ETH
ComposableStablePool Critical Difference
Standard WeightedPool (rsETH/WETH):
Index 0: BPT token
Index 1: rsETH
Index 2: WETH
ComposableStablePool (osETH/WETH, wstETH/WETH, Arbitrum pool):
Index 0: WETH
Index 1: BPT
Index 2: osETH/wstETH
Key Technical Details
Swap Execution Pattern
|
|
swap() Function
|
|
Contracts & Addresses
Core Contracts
| Contract | Address |
|---|---|
| Balancer V2 Vault | 0xBA12222222228d8Ba445958a75a0704d566BF2C8 |
| Fee Collector | 0xce88686553686da562ce7cea497ce749da109f9f |
Attacker Addresses
| Chain | Address |
|---|---|
| Ethereum | 0xAa760D53541d8390074c61DEFeaba314675b8e3f |
| Base | 0x56e5Adab68b594B0c2aD6C112D94AE5aCA98A001 |
Ethereum Pools
| Pool | Pool ID | Contract |
|---|---|---|
| wstETH-rETH-sfrxETH-BPT | 0x5aee1e99fe86960377de9f88689616916d5dcabe000000000000000000000467 |
0x5aEe1e99fE86960377DE9f88689616916D5DcaBe |
| weETH/ezETH/rswETH | 0x848a5564158d84b8a8fb68ab5d004fae11619a5400000000000000000000066a |
0x848a5564158d84b8A8fb68ab5D004Fae11619A54 |
| ankrETH/wstETH | 0xdfe6e7e18f6cc65fa13c8d8966013d4fda74b6ba000000000000000000000558 |
0xdfE6e7e18f6Cc65FA13C8D8966013d4FdA74b6ba |
| ezETH-WETH-BPT | 0x596192bb6e41802428ac943d2f1476c1af25cc0e000000000000000000000659 |
0x596192bB6e41802428Ac943D2f1476C1Af25CC0E |
| cdcETH/wstETH | 0x740a691bd31c4176bcb6b8a7a40f1a723537d99d0000000000000000000006b6 |
0x740A691bd31c4176BCb6B8A7a40f1A723537D99d |
| weETH/rETH | 0x05ff47afada98a98982113758878f9a8b9fdda0a000000000000000000000645 |
0x05ff47AFADa98a98982113758878F9A8B9FddA0a |
| wstETH-rETH-sfrxETH-BPT | 0x42ed016f826165c2e5976fe5bc3df540c5ad0af700000000000000000000058b |
0x42ED016F826165C2e5976fe5bC3df540C5aD0Af7 |
| rsETH/WETH | 0x58aadfb1afac0ad7fca1148f3cde6aedf5236b6d00000000000000000000067f |
0x58AAdFB1Afac0ad7fca1148f3cdE6aEDF5236B6D |
| osETH/WETH | 0xdacf5fa19b1f720111609043ac67a9818262850c000000000000000000000635 |
0xDACf5Fa19b1f720111609043ac67A9818262850c |
| wstETH/WETH | 0x93d199263632a4ef4bb438f1feb99e57b4b5f0bd0000000000000000000005c2 |
0x93d199263632a4EF4Bb438F1feB99e57b4b5f0BD |
Arbitrum Pools
| Pool | Pool ID | Contract |
|---|---|---|
| wstETH/rETH/cbETH | 0x4a2f6ae7f3e5d715689530873ec35593dc28951b000000000000000000000481 |
0x4a2F6Ae7F3e5D715689530873ec35593Dc28951B |
Base Pools
| Pool | Pool ID | Contract |
|---|---|---|
| rETH-WETH-BPT | 0xc771c1a5905420daec317b154eb13e4198ba97d0000000000000000000000023 |
0xC771c1a5905420DAEc317b154EB13e4198BA97D0 |
| weETH/wETH | 0xab99a3e856deb448ed99713dfce62f937e2d4d74000000000000000000000118 |
0xaB99a3e856dEb448eD99713dfce62F937E2d4D74 |
| cbETH/WETH | 0xfb4c2e6e6e27b5b4a07a36360c89ede29bb3c9b6000000000000000000000026 |
0xFb4C2E6E6e27B5b4a07a36360C89EDE29bB3c9B6 |
Additional Vulnerable Pools (Not exploited in this PoC)
- ankrETH/wstETH:
0xdfe6e7e18f6cc65fa13c8d8966013d4fda74b6ba000000000000000000000558 - cdcETH/wstETH:
0x740a691bd31c4176bcb6b8a7a40f1a723537d99d0000000000000000000006b6 - .... (soon..)
Post-Mortem Resources
- SlowMist - When Small Flaws Collapse a Giant
- Checkpoint Research - How an Attacker Drained $128M
- Lookonchain Twitter
- Phalcon Twitter Thread 1
- Phalcon Twitter Thread 2
- realtommybibi Twitter
- AdiFlips Twitter
- GoPlusSecurity Twitter
- KaihuaQIN Twitter
- Markets.com Analysis
- Protos Live Updates
- hklst4r Twitter
- Coinspect Blog
- BlockSec In-Depth Analysis
- BlockSec Explorer - ETH TX 1
- BlockSec Explorer - Arbitrum TX
- BlockSec Explorer - ETH TX 2