Introduction to Balancer Protocol

Balancer v2 is a DeFi protocol and one of the largest automated market makers (AMMs) in the Ethereum ecosystem. It allows users to create and manage liquidity pools with multiple tokens and varying weights, enabling efficient trading and liquidity provision. Balancer v2 introduced several improvements over its predecessor, including enhanced gas efficiency, flexible pool configurations, and support for custom fee structures.

Balancer Core Concept
Balancer Exploit History

Balancer has been hit by three major security incidents over the past five years, revealing a familiar pattern of protocol-level edge cases being weaponized with large financial consequences, including:

Decoding Contract Logic Flaws

  1. Precision Flaw in Rounding Scaling The issue was not a case of random rounding noise accumulating over repeated swaps. The real vulnerability came from a directional mismatch in how amounts were scaled inside Balancer’s ComposableStablePool. Different code paths applied opposite rounding directions when converting values between scaled and unscaled domains, creating a subtle but systematic bias. When exercised repeatedly through batchSwap, this bias allowed the attacker to consistently understate the required input amount and gradually weaken the pool invariant.
    1. ScalingHelpers Contract The vulnerability originated from contradicting scaling directions in core helpers. The _upscale path used mulUp to round values upward, _downscaleDown relied on divDown to round downward, and _downscaleUp used divUp to round upward again. Because these were applied inconsistently across swap paths, amounts would drift in a predictable direction every time the attacker cycled between upscaling and downscaling. This directional drift — not accidental rounding noise — is what mathematically enabled the exploit.
      small zoom
      1. _upscale & _upscaleArray functions: These helper variants apply the scaling factor using a multiplication helper that performs a round up mulUp in certain swap paths (notably the EXACT_OUT path). When the scaling factor is non-integer, rounding up pushes token amounts away from the true fractional value, introducing a systematic bias when used repeatedly. Attackers used tiny, repeated micro-swaps to push balances to rounding boundaries and exploit that bias.
      2. _downscaleDown & _downscaleDownArray functions: These functions rescale using division with a with round down helper using divDown. When paired with the upscale semantics above, the net effect across a swap sequence is asymmetric: one direction systematically loses precision relative to the other, allowing the invariant (D) and derived BPT price to be underestimated after repeated operations.
      3. _downscaleUp & _downscaleUpArray functions: These variants use a round up using divUp in other code paths. The coexistence of both divUp and divDown in different swap/settlement paths is what created the directional rounding bias — not a single mistaken operator. Properly symmetric scaling (same rounding direction for complementary operations) would have prevented the exploitable drift.
    2. BaseGeneralPool Contract This contract implements ScalingHelpers for normalizing token amounts during swaps providing general specialization settings hook for all derived contracts.
    3. ComposableStablePool Contract
      1. Implements the specific logic for composable stable pools by inheriting BaseGeneralPool contract
      2. Adds preminted BPT by overriding BaseGeneralPool onSwap function in _swapGivenOut .
      3. _swapGivenOut checks if the swap involves BPT then it executes _swapWithBpt to treat the swap as a single swap which computes the amountOut using the swapFeePercentage to charge the protocol fees.
    4. Swaps Contract. The exploit hinged on how Balancer’s batchSwap mechanism interacts with ComposableStablePools. In Balancer v2, swaps are not executed token-to-token in isolation; instead, Vault.batchSwap defers all balance settlement until the end of the multi-step swap. This design temporarily allows a contract to “borrow” intermediate balances inside the same transaction. When combined with the contradicting scaling directions in ScalingHelpers each micro-swap slightly understated the true token input required. Because the vault only settles deltas at the end, the attacker chained dozens of these under-priced swaps in one atomic call — compounding the imbalance and pushing the invariant into an artificially favorable state.
  2. Improper Access Control Another crucial vulnerability is the validation loophole that allows user to manage their balances and handles transfers and withdrawals.
    1. manageUserBalance function This function processes user balance operations depending on their specified kind, allowing a user or an approved relayer to _depositToInternalBalance, _withdrawFromInternalBalance, _transferInternalBalance, and _transferToExternalBalance. It relies on a validation step to determine whether the caller is authorized to perform the requested action.
    2. _validateUserBalanceOp function This function compares the caller (msg.sender) to a user-supplied op.sender to decide whether the caller is acting as the user or as an approved relayer. The flaw was that the protocol trusted op.sender as provided by the caller without independently verifying it. The attacker simply set op.sender = msg.sender, preventing the relayer-approval branch from running entirely. As a result, the malicious contract was treated as the legitimate owner of the internal balance, enabling unauthorized _withdrawFromInternalBalance calls to drain other users’ funds.

Hack Analysis

In preparation for the exploit, the attacker gathered pool balances, and scaling factors, protocol fees, and BPT rate then repeatedly simulated swaps tailored to the token decimals via a malicious deployed contract that included console.log statements which strongly suspected that parts of the exploit were vibe-coded — written or generated quickly without the usual production cleanup. These console.log calls are common during development for debugging but are almost never left in deployed contracts because they consume unnecessary gas. Their presence suggests the attacker may have relied on an LLM-generated template or hastily assembled the exploit code with minimal revision, copy-pasting directly from an AI output or test script into mainnet deployment.

  1. Attacker EOA labeled Balancer Exploiter 1 0x506D1f9EFe24f0d47853aDca907EB8d89AE03207 deployed 2 malicious contracts:
    small zoom
    1. Malicious Contract 1: 0x54b53503c0e2173df29f8da735fbd45ee8aba30d
    2. Malicious Contract 2: 0x679b362b9f38be63fbd4a499413141a997eb381e
  2. Used Malicious Contract 1 to wrap ETH on OsETH/WETH-BPT pool.
    small zoom
    1. Wrapped ETH to WETH via Balancer Vault Contract
    2. Deposited WETH into OsETH/WETH-BPT pool via ComposableStablePool Contract
    3. Performed this transaction to get initial pool status of balance, amplified fees, scaling factors, and BPT rate.
  3. Simulated repetitive swaps from Malicious Contract 1 that previously wrapped ETH utilizing Malicious Contract 2 address via helper function with selector 0x524c9e20 passing various balances’ arrays, amplified, scaling factors, token indices, fee, and anticipated amountOut to expose the precise rounding flaw that helped attacker perform real exploit.
  4. Executed the first exploit from Malicious Contract 1 by batchSwap internal underlying balance of wrapped ETH in OsETH/WETH-BPT pool to swap BPT for OsETH utilizing the crafted rounding flaw because of the asymmetry upscale and downscale conflicting rounding directions to input token amount smaller than required which consequently reduced the pool variants.
    small zoom
    small zoom
  5. Last batch swap on Ethereum was wstETH-WETH-BPT pool by last batchSwap of internal underlying balance of wrapped ETH.
    small zoom
  6. Last exploit phase was clean withdrawal of all accumulated balances from Malicious Contract 1 using manageUserBalance function to withdraw internal balances to EOA labeled Balancer Exploiter 2.
    1. Withdrew 6,587.440315017497938362 WETH.
    2. Withdrew 44.154666355785411629 Balancer osETH/wETH.
    3. Withdrew 6,851.122954235076557965 Staked ETH osETH.
    4. Withdrew 4,259.843451780587743322 Wrapped Liquid ETH wstETH.
    5. Withdrew 20.413668455251157822 Balancer wstETH-WETH-BPT.
      medium zoom
  7. Improper access control, missing pause protection and centralized pool operations amplified the exploit
    1. Improper access flaw allowed the attacker to withdraw users’ funds from their accounts using _withdrawFromInternalBalance function bypassing the access check of whether the caller is either the contact caller or relayer approved by the owner _validateUserBalanceOp. The exploit extended beyond just manipulating contradicting rounding directions to withdrawing other users’ funds from their internal balances.
      
      
                                          function manageUserBalance(
                                              UserBalanceOp[] memory ops
                                              ) external {
                                              
                                              // rest of the function code...
      
                                              (
                                                  kind, 
                                                  asset, 
                                                  amount, 
                                                  sender, 
                                                  recipient, 
                                                  checkedCallerIsRelayer
                                              ) = _validateUserBalanceOp(
                                                  ops[i],
                                                  checkedCallerIsRelayer
                                              );
      
                                              // rest of the function code...
      
                                          }
                                      
      
                                          function _validateUserBalanceOp(
                                              UserBalanceOp memory op, 
                                              bool checkedCallerIsRelayer
                                              )
                                              private
                                              view
                                              returns (
                                                  UserBalanceOpKind,
                                                  IAsset,
                                                  uint256,
                                                  address,
                                                  address payable,
                                                  bool
                                              )
                                          {
                                              address sender = op.sender;
      
                                              if (sender != msg.sender) {
                                                  if (!checkedCallerIsRelayer) {
                                                      _authenticateCaller();
                                                      checkedCallerIsRelayer = true;
                                                  }
      
                                                  _require(
                                                      _hasApprovedRelayer(sender, msg.sender), 
                                                      Errors.USER_DOESNT_ALLOW_RELAYER
                                                  );
                                              }
      
                                              return (
                                                  op.kind, 
                                                  op.asset, 
                                                  op.amount, 
                                                  sender, 
                                                  op.recipient, 
                                                  checkedCallerIsRelayer
                                              );
                                          }
                                      
    2. Missing pause protection on other chains like Arbitrum, Polygon, Base, Sonic, and Opimism meant there was no emergency mechanism to halt operations during the exploit window, allowing the attacker to continue exploiting the vulnerability without interruption.
      
      
                                              function manageUserBalance(UserBalanceOp[] memory ops) external {
      
                                                  bool checkedNotPaused = false;
                                                  
                                                  // rest of the function code...
      
                                                  if (!checkedNotPaused) {
                                                      _ensureNotPaused();
                                                      checkedNotPaused = true;
                                                  }
      
                                                  // rest of the function code...
      
                                              }
                                          
    3. The Balancer protocol's centralized pool operations, which aggregate all pool tokens within a single Vault contract, significantly amplified the impact of the November 2025 exploit by allowing a single vulnerability in the ComposableStablePools' arithmetic logic to affect every pool simultaneously across multiple blockchain networks. This design, intended to reduce gas costs and enable capital efficiency, created a single point of failure where a subtle rounding error in the _upscaleArray function could be weaponized to systematically deflate pool invariants and drain over $128 million in less than half an hour.
      1. The centralized Vault Contract holds all tokens across all pools, meaning a flaw in the core math logic of the ComposableStablePools could ripple across the entire protocol.
      2. The exploit leveraged the protocol’s Internal Balance system, which allowed the attacker to accumulate stolen funds within the exploit contract’s internal balance during the constructor phase before withdrawing them externally, delaying detection.
      3. Because the Vault is shared across all pools, the precision loss vulnerability—triggered when balances were driven to the 8–9 wei range—affected every ComposableStablePool, enabling the attacker to execute the same exploit sequence across Ethereum, Arbitrum, Base, Polygon, Optimism, and Sonic.
      4. The interconnectedness of the pools meant that a single vulnerability in the invariant calculation could be amplified through batched swap operations, allowing the attacker to repeatedly manipulate BPT pricing and extract value at artificially low rates.

Affected Assets

Root Cause Summary

Security Takeaways

Post-Hack Mitigation

Balancer immediately initiated its post-incident recovery procedures, publishing an on-chain message offering a one-time 20% white-hat bounty to the attacker in exchange for the full return of stolen assets to a designated recovery address. The message emphasized that active blockchain forensics were underway and that the team was cooperating with law-enforcement agencies. The bounty window was limited to 48 hours, signaling urgency and encouraging voluntary restitution.

medium zoom

Forensics teams also highlighted that the attacker funded the exploit transaction using 100 ETH withdrawn from Tornado Cash, with no corresponding recent deposits of that size. This pattern suggests that the attacker likely relied on proceeds from earlier hacks rather than newly sourced funds, indicating prior operational maturity and possibly a history of similar exploits.

StakeWise DAO activated its emergency multisig shortly after the attack and executed a coordinated set of on-chain actions to reclaim stolen assets. Through these efforts, the DAO successfully recovered 5,041 osETH (≈ $19M) and 13,495 osGNO (≈ $1.7M) directly from the exploiter’s address.

On Ethereum mainnet, the reclaimed osETH accounts for roughly 73.5% of the 6,851 osETH originally drained from the Balancer pool. The remainder had already been swapped into ETH before recovery actions were initiated. All stolen osGNO, however, were retrieved in full. StakeWise stated that the returned assets will be redistributed to users affected by the exploit, allocated pro-rata according to their balances at the moment of the attack. The DAO also announced that a complete post-mortem and a detailed plan for next steps will be released once the recovery and accounting processes are finalized.

Incident Report Table

Severity
Critical Direct funds drainage or take control of protocol.
High Severe losses or control issues with some conditions.
Medium Issues that degrade security or amplify bugs.
Low Minor edge cases & small impact.
Informational No direct exploit, but harmful to trust.
Impact
High Directly enabled liquidity drain, major financial loss.
Medium Amplified exploit or weakened tokenomics.
Low Limited effect, cosmetic, or edge-case outcome.
Likelihood
High Trivial to exploit or already weaponized in attack.
Medium Requires conditions or chaining but practical.
Low Edge cases, rare alignment, or theoretical.
Status
Exploited Confirmed used in the $2M NGP hack.
Contributed Amplified the overall exploit.
Design Risk Introduces structural weakness.
Severity Likelihood Status Impact Vulnerability Description
Critical High Exploited High Contradicting Scaling Directions
mulUp
divDown
divUp
Inconsistent rounding logic between upscale and downscale paths created a deterministic precision drift. Attacker looped batch swaps to understate required input amounts and collapse pool invariants across all ComposableStablePools.
Critical High Exploited High Asymmetric Batch Swap Settlement Balancer’s deferred settlement in batchSwap allowed multiple biased calculations in a single transaction. Precision errors compounded before balances were committed, enabling systemic extraction of value across multiple pools.
Critical High Exploited High Improper Access Control
manageUserBalance()
_validateUserBalanceOp()
Protocol trusted user-supplied op.sender without verifying true ownership. Attacker set op.sender = msg.sender, bypassed relayer checks, and executed unauthorized withdrawals via _withdrawFromInternalBalance.
High High Design Risk High Centralized Vault Architecture A single Vault holding all assets meant one arithmetic flaw in ComposableStablePools propagated across Ethereum, Arbitrum, Base, Polygon, Optimism, and Sonic. Increased blast radius and amplified systemic damage.
High Medium Contributed Medium Missing Pause Protection Across Chains No synchronized pause system on secondary networks allowed attacker to continue draining pools after the vulnerability was identified. Exploit continued uninterrupted across chains.
High Low Exploited Medium Internal Balance Abuse Internal Balance allowed accumulation of manipulated amounts without immediate on-chain withdrawals. Enabled delayed detection and extended exploit execution window.

Relevant Links

Reverse Engineering the Exploiter’s Contracts

The attacker deployed two cooperating contracts, and the bytecode of both reveals a complete, self-contained Balancer-math laboratory engineered specifically to test, measure, and weaponize the contradictory rounding rules inside ComposableStablePools. This was not a simple drain script — it was a purpose-built exploit environment that probes pool state through raw selectors, performs swap simulations, logs internal values with Hardhat-style console.log strings, and then executes the precision-manipulation exploit only after confirming that the invariant has drifted into a profitable range.

  1. Exploit Contract 1 — The Exploit Engine Contract 1 is the attacker’s main controller. It is responsible for gathering low-level pool information, modeling Balancer math, and performing the final exploit. The proof is embedded directly in the bytecode through a collection of function selectors and debugging strings that correspond exactly to the variables the attacker monitored.
    1. Evidence of direct pool-state interrogation: Contract 1 uses the following selectors to fetch the exact values it logs:
      1. Current token balances 0x70a08231 —> balanceOf(address) Used repeatedly inside loops that immediately precede the debug strings "mytoken i" and "mybal i". This confirms the contract pulled each token’s on-chain balance before simulating swaps.
      2. Internal Balancer Vault balances 0x0f5a6efa —> getInternalBalance(address user, address[] tokens) Called directly before blocks containing "Asset Deltasi" and "Done with amts1", proving the contract compared external balances with Vault internal balances to track intermediate deltas.
      3. BPT / pool-rate reads 0x679aefce —> getRate() These calls sit immediately before the debug strings "poolRate0" and "poolRate1", showing that the attacker was reading the BPT exchange rate and using it in invariant simulations.
      4. Batch swap result path 0x945bcec9 —> batchSwap(...) This selector appears directly before the debug string "Asset Deltasi" showing that the contract logs the raw int256[] deltas returned by the Vault.
      5. Amplification and invariant reads 0x0e8e3e84 —> amplification getter Appears in code regions that print "currentAmp" and "startBalancesi". 0x82687a56 —> invariant/swap-math view Appears immediately before "End__Invariant".
    2. Embedded debug strings (direct proof of internal math reconstruction): Contract 1 contains literal ASCII strings hard-coded into the deployed runtime — behavior typical only of Hardhat/Foundry console.log, never of a production exploit.
      1. Pool and rate tracing
        • "poolRate0"
        • "poolRate1"
      2. Token and balance tracing
        • "mytoken i"
        • "mybal i"
        • "startBalancesi"
      3. Amplification and invariant tracing
        • "currentAmp"
        • "End__Invariant"
      4. Batch swap output tracing
        • "Doing Batch"
        • "Asset Deltasi"
        • "Done with amts1"
      5. Exploit-search variables
        • "nonTrickIndex"
        • "trickAmt"
        • "trickRate"
      6. Execution markers
        • "Start."
    3. Custom swap-simulation and math routines: The contract uses the above selectors to rebuild Balancer’s stable math inside the bytecode. It performs:
      1. full invariant recalculation
      2. upscale/downscale replications
      3. simulated EXACT_OUT swaps
      4. fuzzing loops around "trickAmt" and "trickRate"
      5. swap batching under "Doing Batch"
      6. invariant checks under "End__Invariant"
    4. Internal balance staging: After simulating and confirming drift, Contract 1 uses getInternalBalance, batchSwap, and ERC20 transferFrom calls to stage manipulated deltas in Balancer’s internal accounting. This is where the debug string "Asset Deltasi" corresponds directly to returned values from the batchSwap selector 0x945bcec9.
    5. Vibe-coded console.log fingerprint: The presence of all those raw debug strings "Start.", "Doing Batch", "currentAmp", "End__Invariant", and "trickAmt" in the final deployed bytecode proves:
      1. the contract was vibe-coded
      2. likely assembled quickly with LLM/test harness assistance
      3. deployed without stripping development instrumentation
      4. used as a live invariant-testing harness on mainnet
  2. Contract 2 — The Helper Module Contract 2 is much smaller and serves as a remote test target for Contract 1. Its purpose is not to manipulate Balancer directly, but to offload selector tests and sanity-check behaviors during the invariant-search phase.
    1. Selector-reaction testing Contract 1 sends encoded calls to Contract 2 to:
      1. test argument formats
      2. verify return value shapes
      3. confirm correct indices before passing them into Balancer math
      4. validate “trick indexes” "nonTrickIndex" and trial values "trickRate" and "trickAmt"
      This modular design keeps Contract 1’s state clean while Contract 2 handles discardable test calls.
    2. Mathematical cross-checking Contract 2 returns structured values used by Contract 1’s invariant routines to compare:
      1. predicted deltas
      2. scaling factor boundaries
      3. manipulated indices
      4. intermediate values used in "Done with amts1" blocks
      5. The presence of these signature debug logs confirms the cross-contract feedback loop.

Level Up Your Security Skills with Cyfrin Updraft

A hands-on smart contract security education platform designed to help developers, auditors, and protocol teams master adversarial thinking through challenges, tutorials, and real-world exploit case studies.

Start Learning