Understanding the Balancer v2 Exploit: Technical Analysis and Lessons for the Blockchain Industry
Excerpt; open the original source for the complete text.
November 7, 2025
Demian Brener, Dario Lo Buglio, Mateo Cesaroni, Christian Santagata, John Neufeld
The recent exploit of Balancer v2 Composable Stable Pools, which resulted in losses exceeding $120M, has understandably shaken the blockchain community. OpenZeppelin conducted security audits of earlier versions of the Balancer v2 codebase, prior to the introduction of the attack vector that was later exploited.
We're now sharing our technical analysis of the exploit to help educate the community about this complex attack and to advance the conversation about effective security best practices in our rapidly evolving industry.
This post is divided into two sections:
Technical analysis of the attack
Learnings and opportunities to improve blockchain security practices
1. Exploit Analysis
1.1 The Attack
On November 3, 2025 at around 7:40 AM UTC specific Balancer v2 stable pools were targeted with a sophisticated attack that resulted in a cumulative loss of $120+ million across Balancer and its forks.
The attack leveraged several circumstances:
The majority of the targeted pools were instances of the
ComposableStablePoolcontract. A special pool designed for assets that are either expected to consistently swap at near parity, or at a known exchange rate.The
batchSwapfunctionality of the Balancer** **Vaultcontract, which allows for transient swaps to happen before the need to settle the outstanding deltas.The attack was profitable in low liquidity scenarios. The attacker had to prepare large swaps so that pools’ balances could be brought to low liquidity of one or other tokens. The most used way of achieving this was to swap LP tokens for pool tokens, leaving the pool in a low liquidity state.
All of the above are just necessary but not conclusive circumstances, with the only necessary circumstance for this to happen to be the root cause of the issue, a rounding behaviour in the _upscale function.
The attack vector was introduced first on July 16, 2021 when MetaStablePool overrode the _scalingFactors function at commit 059284e. The same change has been later applied on September 1, 2021 for linear pools at commit 4e9e70a and on September 20, 2021 at commit f450760 for StablePhantomPool, later renamed ComposableStablePool. OpenZeppelin’s prior audits did not cover any of those pools presenting this attack vector.
In the issue description below, we explain why the introduction of this override was at the core of the attack.
1.2 The Issue
The batchSwap function of the Vault contract is the entry point for this attack. The attacker crafted a call to it, targeting instances of ComposableStablePool. The relevant logic in the Vault contract that brings to the ComposableStablePool interaction is defined by this flow:
batchSwaps* → internally calls _swapWithPools → which internally calls a pool for each swap at _swapWithPool → [_processGeneralPoolSwapRequest](https://github.com/balancer/balancer-v2-monorepo/blob/master/pkg/vault/contracts/Swaps.sol#L295)**→ ultimately callsonSwap on the ComposableStablePool.*
Once landed in ComposableStablePool, the execution continues within onSwap hook where two things happen:
The hand over to
_swapGivenOutas defined in the example transaction provided above. Notice that the attacker can have decided to take the_swapGivenInpattern but the provided inputs in the attack transaction clearly show the choice. This function will call the_upscalefunction.
Let's take a look at these two functions.
_scalingFactors
This function does two things:
It factors in the token exchange rate by multiplying it by the scaling factor and dividing by 1e18.
_upscale
Which just performs a multiplication of amount by its scaling factor, dividing by 1e18 at the end.
The issue lies in two facts:
These functions always round-down (
mulDown) independently from the direction of the swap.If amounts are orders of magnitude less than
scalingFactorsones, the precision loss becomes non-negligible. In the attacker transaction, a typical value of amount and scaling factor are:
amount: "17"
scalingFactor: "1058132408689971699"
If we do amount*scalingFactor / 1e18 we would obtain** **17,98 but this would truncate to 17 in Solidity, leading to a 0% net change in the _upscale function. Conversely, if we now imagine an amount of 17000000000000000000 (a token with 18 decimals) we obtain 17988250950000000000 which represents a 5.8% increase (the rate indeed). Similarly, if we do a token with 6 decimals as most stablecoins 17000000 we would obtain 17988250 which is still roughly the same positive increment in percentage.
Truncation is maximized by making amount*scalingFactor mod 1e18 as large as possible, so the product loses the maximum precision when divided by 1e18.
For example, in the table below, amount=17 and amount=50 both produce an absolute rounding loss of about 0.90. However, error as a percentage of amount differs significantly, and the percent of increase lost diverges even more, since the same absolute loss is much larger relative to 17 than to 50.
The _swapGivenOutfunction will later use these rounded values to calculate the amount owed by the attacker to the pool after the swap. This value is artificially deflated, making the swap cheaper. The inner mechanics of the _swapGivenOut are complex and they also justify why this can be achieved in pools where low liquidity states can be obtained at will. This is due to an invariant check which is ensuring convergence to some degree. In high liquidity situations, big shifts in the balances would have failed due to the same invariant protection.
Ultimately, with a high number of iterations, the deltas after the batchSwap are inflated, crediting to the attacker the majority of the funds in the pool.
Additionally, there’s a pertinent observation to be made about the _upscale in-line docstrings:
/* Upscale rounding wouldn't necessarily always go in the same direction: in a swap for example the balance of token in should be rounded up, and that of token out rounded down. This is the only place where we round in the same direction for all amounts, as the impact of this rounding is expected to be minimal. */
A prior version of this comment had an additional note:
/* …as the impact of this rounding is expected to be minimal (and there's no rounding error unless _scalingFactor() is overriden). */
This is relevant since the StablePhantomPool introduced on September 20, 2021, which is later renamed to ComposableStablePool, implements exactly the override in _scalingFactor that we discussed above. Previously, in an old version of StablePool the _scalingFactor function only accounted for token decimals differences, and as already seen, now it factors in the exchange rate too. This is fundamentally different, since as the _upscale comment signals, the code went from unitary scaling factors (ie 1e12) to non-unitary exchange rates. This shift is what the _upscale warns against, opening the door for rounding errors and thus for the attack vector.
While enabling the rounding error was the root cause, exploiting it required leveraging additional protocol mechanics and specific attack steps.
Balancer’s batchSwap allows transient internal balances that are only net-settled at the end of the batch. Thanks to this, the attacker effectively “borrowed” BPT within a batch to manipulate the pool without needing to end the transaction holding BPT.
A BPT (Balancer Pool Token) is an ERC-20 that represents a pro-rata share of a Balancer pool. In some pool designs, including the targeted ComposableStablePools, the BPT can also appear as a pool asset and be tradable/swappable.
The first phase pushed pool token balances (e.g., WETH / osETH) to very low levels (≲ 100k) by repeatedly swapping BPT to token₁ and BPT to token₂.
With balances small, fixed-point rounding becomes dominant. The objective is to maximize the discarded fractional part in floor arithmetic, i.e., maximize amount*scalingFactor mod 1e18 so that the product loses as much precision as possible when divided by 1e18. At sufficiently low balances, **the entire intended increment can be truncated away, **this is where the maximum is attained and what the hacker looks for.
The exploit runs in repeating triplets of swaps:
Prime: move the pool to a state where truncation will occur on the next operation. In the screenshot below, a swap of WETH to osETH
Exploit: perform the swap that realizes the rounding loss. In the screenshot, a swap of 17 WETH for osETH.
Reset: restore balances so the triplet can be replayed. In the screenshot is the swap of osETH to WETH.
As shown in the trace below, the critical swap often uses an amount=17 against a vault balance of 18. You can see the repeated 17 amounts in the second swap of each triplet.
1.3 Audit Scope in Relation to the Exploit
We believe it is important to provide specific details regarding OpenZeppelin’s audits of earlier versions of Balancer v2 due to misconceptions presented in the media regarding our involvement and security auditing in general.
The attack vector exists in code (ComposableStablePool and Linear pools) that was added after the OpenZeppelin audit concluded and was not part of our audit scope. The contracts the OpenZeppelin team audited (StablePool and WeightedPool) did not contain the _scalingFactors override that returns non-unitary values and enables this exploit.
OpenZeppelin conducted two audits of Balancer v2:
**
First Audit (February 8 - March 15, 2021)**
Scope: StablePool, WeightedPool, and all their dependencies (BaseGeneralPool, BasePool), the Vault contract with its batch swap functionality, authorization, fee management, and pool factory contracts
Here the _upscale function presented the referenced comment, which assumed safety as long as no overrides were made to the _scalingFactors function.
**
Second Audit (August 23 - September 10, 2021)**
Scope: Changes to StablePool and StableMath implementations
The critical detail is what happened during and after our second audit. New contracts were added to the repository but were not within the scope of our engagement:
LinearPool - added September 1, 2021