Risk Warning: Beware of illegal fundraising in the name of 'virtual currency' and 'blockchain'. — Five departments including the Banking and Insurance Regulatory Commission
Information
Discover
Search
Login
简中
繁中
English
日本語
한국어
ภาษาไทย
Tiếng Việt
BTC
ETH
HTX
SOL
BNB
View Market
Balancer hacked, vulnerability analysis
ExVul Security
特邀专栏作者
@exvulsec
2025-11-04 08:19
This article is about 11217 words, reading the full article takes about 17 minutes
On November 3, 2025, the Balancer protocol suffered a hacker attack on multiple public chains, including Arbitrum and Ethereum, resulting in a loss of $120 million in assets. The core of the attack stemmed from a dual vulnerability: loss of precision and manipulation of invariants. The key issue in this attack lay in the protocol's logic for handling small transactions.

Foreword

On November 3, 2025, the Balancer protocol was attacked by hackers on multiple public chains such as Arbitrum and Ethereum, resulting in a loss of $120 million in assets. The core of the attack stemmed from a dual vulnerability of precision loss and invariant manipulation.

The key issue in this attack lies in the protocol's logic for handling small transactions. When a user makes a small exchange, the protocol calls the _upscaleArray function, which uses mulDown to round the value down. If the balance in the transaction and the input amount both fall within a specific rounding boundary (e.g., the 8-9 wei range), a significant relative precision error occurs.

The accuracy error propagated to the calculation of the invariant value D in the protocol caused the value of D to be abnormally reduced. The change in the value of D directly lowered the price of BPT (Balancer Pool Token) in the Balancer protocol. Hackers exploited this depressed price of BPT to complete arbitrage through pre-designed transaction paths, ultimately causing huge asset losses.

Vulnerability exploit link: https://etherscan.io/tx/0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742

Asset transfer link: https://etherscan.io/tx/0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569

Technical Analysis

Attack entry point

The attack entry point is the Balancer: Vault contract, and the corresponding entry function is the batchSwap function, which internally calls onSwap to perform token swaps.

 function onSwap(
 SwapRequest memory swapRequest,
 uint256[] memory balances,
 uint256 indexIn,
 uint256 indexOut
 ) external override onlyVault(swapRequest.poolId) returns (uint256) {
 _beforeSwapJoinExit();

 _validateIndexes(indexIn, indexOut, _getTotalTokens());
 uint256[] memory scalingFactors = _scalingFactors();

 return
 swapRequest.kind == IVault.SwapKind.GIVEN_IN
 ? _swapGivenIn(swapRequest, balances, indexIn, indexOut, scalingFactors)
 : _swapGivenOut(swapRequest, balances, indexIn, indexOut, scalingFactors);
 }

From the function parameters and restrictions, we can obtain several pieces of information:

  1. Attackers need to call this function through Vault; they cannot call it directly.
  2. The function internally calls _scalingFactors() to obtain scaling factors for scaling operations.
  3. Scaling operations are handled in either _swapGivenIn or _swapGivenOut .

Attack Pattern Analysis

BPT Price Calculation Method

In Balancer's stable pool model, the price of BPT is an important reference point, which determines how many BPTs a user receives and how many assets are received per BPT.

 BPT Price = D / totalSupply

Where D = invariant, from Curve's StableSwap model.

In the pool exchange calculation:

 // StableMath._calcOutGivenIn
 function _calcOutGivenIn(
 uint256 amplificationParameter,
 uint256[] memory balances,
 uint256 tokenIndexIn,
 uint256 tokenIndexOut,
 uint256 tokenAmountIn,
 uint256 invariant
 ) internal pure returns (uint256) {
 /**********************************************************************************************************
 // outGivenIn token x for y - polynomial equation to solve //
 // ay = amount out to calculate //
 // by = balance token out //
 // y = by - ay (finalBalanceOut) //
 // D = invariant DD^(n+1) //
 // A = amplification coefficient y^2 + ( S + ---------- - D) * y - ------------- = 0 //
 // n = number of tokens (A * n^n) A * n^2n * P //
 // S = sum of final balances but y //
 // P = product of final balances but y //
 **************************************************************************************************************/

 // Amount out, so we round down overall.
 balances[tokenIndexIn] = balances[tokenIndexIn].add(tokenAmountIn);

 uint256 finalBalanceOut = _getTokenBalanceGivenInvariantAndAllOtherBalances(
 amplificationParameter,
 balances
 invariant, // using the old D
 tokenIndexOut
 );

 // No need to use checked arithmetic since `tokenAmountIn` was actually added to the same balance right before
 // calling `_getTokenBalanceGivenInvariantAndAllOtherBalances` which doesn't alter the balances array.
 balances[tokenIndexIn] = balances[tokenIndexIn] - tokenAmountIn;

 return balances[tokenIndexOut].sub(finalBalanceOut).sub(1);
 }

The portion that serves as the benchmark for BPT prices is a constant value D ; that is, manipulating BPT prices requires manipulating D. Let's analyze the calculation process of D:

 // StableMath._calculateInvariant
 function _calculateInvariant(uint256 amplificationParameter, uint256[] memory balances)
 internal
 pure
 returns (uint256)
 {
 /**********************************************************************************************
 // invariant //
 // D = invariant D^(n+1) //
 // A = amplification coefficient A n^n S + D = AD n^n + ----------- //
 // S = sum of balances n^n P //
 // P = product of balances //
 // n = number of tokens //
 **********************************************************************************************/

 // Always round down, to match Vyper's arithmetic (which always truncates).

 uint256 sum = 0; // S in the Curve version
 uint256 numTokens = balances.length;
 for (uint256 i = 0; i < numTokens; i++) {
 sum = sum.add(balances[i]); // balances is the scaled value}
 if (sum == 0) {
 return 0;
 }

 uint256 prevInvariant; // Dprev in the Curve version
 uint256 invariant = sum; // D in the Curve version
 uint256 ampTimesTotal = amplificationParameter * numTokens; // Ann in the Curve version

 // Iterative calculation of D...
 // The calculation of D affects the precision of balances for (uint256 i = 0; i < 255; i++) {
 uint256 D_P = invariant;

 for (uint256 j = 0; j < numTokens; j++) {
 // (D_P * invariant) / (balances[j] * numTokens)
 D_P = Math.divDown(Math.mul(D_P, invariant), Math.mul(balances[j], numTokens));
 }

 prevInvariant = invariant;

 invariant = Math.divDown(
 Math.mul(
 // (ampTimesTotal * sum) / AMP_PRECISION + D_P * numTokens
 (Math.divDown(Math.mul(ampTimesTotal, sum), _AMP_PRECISION).add(Math.mul(D_P, numTokens))),
 invariant
 ),
 // ((ampTimesTotal - _AMP_PRECISION) * invariant) / _AMP_PRECISION + (numTokens + 1) * D_P
 (
 Math.divDown(Math.mul((ampTimesTotal - _AMP_PRECISION), invariant), _AMP_PRECISION).add(
 Math.mul((numTokens + 1), D_P)
 )
 )
 );

 if (invariant > prevInvariant) {
 if (invariant - prevInvariant <= 1) {
 return invariant;
 }
 } else if (prevInvariant - invariant <= 1) {
 return invariant;
 }
 }

 _revert(Errors.STABLE_INVARIANT_DIDNT_CONVERGE);
 }

In the code above, the calculation of D depends on the scaled balances array . This means that an operation is needed to change the precision of these balances, leading to an error in the calculation of D.

The root cause of accuracy loss

 // BaseGeneralPool._swapGivenIn
 function _swapGivenIn(
 SwapRequest memory swapRequest,
 uint256[] memory balances,
 uint256 indexIn,
 uint256 indexOut,
 uint256[] memory scalingFactors
 ) internal virtual returns (uint256) {
 // Fees are subtracted before scaling, to reduce the complexity of the rounding direction analysis.
 swapRequest.amount = _subtractSwapFeeAmount(swapRequest.amount);

 _upscaleArray(balances, scalingFactors); // Key: Upscale the balance swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);

 uint256 amountOut = _onSwapGivenIn(swapRequest, balances, indexIn, indexOut);

 // amountOut tokens are exiting the Pool, so we round down.
 return _downscaleDown(amountOut, scalingFactors[indexOut]);
 }

Scaling operation:

 // ScalingHelpers.sol
function _upscaleArray(uint256[] memory amounts, uint256[] memory scalingFactors) pure {
 uint256 length = amounts.length;
 InputHelpers.ensureInputLengthMatch(length, scalingFactors.length);

 for (uint256 i = 0; i < length; ++i) {
 amounts[i] = FixedPoint.mulDown(amounts[i], scalingFactors[i]); // Round down}
}

// FixedPoint.mulDown
 function mulDown(uint256 a, uint256 b) internal pure returns (uint256) {
 uint256 product = a * b;
 _require(a == 0 || product / a == b, Errors.MUL_OVERFLOW);

 return product / ONE; // Round down: truncate directly}

As shown above, when using _upscaleArray , if the balance is very small (e.g., 8-9 wei), the down-rounding of mulDown will result in a significant loss of precision.

Attack process details

Phase 1: Adjust to rounding boundary

 Attacker: BPT → cbETH
Objective: To adjust the cbETH balance to the rounding boundary (e.g., ending in 9).

Assume the initial state:
 cbETH Balance (Original): ...00000000009 wei (last digit is 9)

Phase 2: Triggering Precision Loss (Core Vulnerability)

 Attacker: wstETH (8 wei) → cbETH

Before scaling:
 cbETH Balance: ...000000000009 wei 
 wstETH input: 8 wei

Execute _upscaleArray:
 // cbETH scaling: 9 * 1e18 / 1e18 = 9
 // But if the actual value is 9.5, it becomes 9 due to rounding down.
 scaled_cbETH = floor(9.5) = 9
 
 Accuracy loss: 0.5 / 9.5 = 5.3% relative error calculation exchange:
 Input (wstETH): 8 wei (scaled)
 Balance (cbETH): 9 (Incorrect, it should be 9.5)
 
 Because cbETH is undervalued, the calculated new balance will also be undervalued, leading to an error in the D calculation.
 D_original = f(9.5, ...)
 D_new = f(9, ...) < D_original

Phase 3: Profiting from the depressed BPT price

 Attacker: Underlying asset → BPT

at this time:
 D_new = D_original - ΔD
 BPT price = D_new / totalSupply < D_original / totalSupply
 
Attackers obtain the same number of BPTs with fewer underlying assets.
Or exchange the same underlying assets for more BPTs

The attacker above used Batch Swap to perform multiple swaps within a single transaction:

  1. First exchange: BPT → cbETH (adjust balance)
  2. Second swap: wstETH (8) → cbETH (triggers precision loss)
  3. Third exchange: Underlying assets → BPT (profit)

These swaps are all within the same batch swap transaction and share the same balance state , but _upscaleArray is called to modify the balances array for each swap.

The lack of a callback mechanism

The main process is started by Vault, so how does this lead to the accumulation of precision loss? The answer lies in the passing mechanism of the balances array .

 // The logic function when Vault calls onSwap: _processGeneralPoolSwapRequest(IPoolSwapStructs.SwapRequest memory request, IGeneralPool pool)
 private
 returns (uint256 amountCalculated)
 {
 bytes32 tokenInBalance;
 bytes32 tokenOutBalance;

 // We access both token indexes without checking existence, because we will do it manually immediately after.
 EnumerableMap.IERC20ToBytes32Map storage poolBalances = _generalPoolsBalances[request.poolId];
 uint256 indexIn = poolBalances.unchecked_indexOf(request.tokenIn);
 uint256 indexOut = poolBalances.unchecked_indexOf(request.tokenOut);

 if (indexIn == 0 || indexOut == 0) {
 // The tokens might not be registered because the Pool itself is not registered. We check this to provide a
 // more accurate revert reason.
 _ensureRegisteredPool(request.poolId);
 _revert(Errors.TOKEN_NOT_REGISTERED);
 }

 // EnumerableMap stores indices *plus one* to use the zero index as a sentinel value - because these are valid,
 We can undo this.
 indexIn -= 1;
 indexOut -= 1;

 uint256 tokenAmount = poolBalances.length();
 uint256[] memory currentBalances = new uint256[](tokenAmount);

 request.lastChangeBlock = 0;
 for (uint256 i = 0; i < tokenAmount; i++) {
 // Because the iteration is bounded by `tokenAmount`, and no tokens are registered or deregistered here, we
 // know `i` is a valid token index and can use `unchecked_valueAt` to save storage reads.
 bytes32 balance = poolBalances.unchecked_valueAt(i);

 currentBalances[i] = balance.total(); // Read from storage request.lastChangeBlock = Math.max(request.lastChangeBlock, balance.lastChangeBlock());

 if (i == indexIn) {
 tokenInBalance = balance;
 } else if (i == indexOut) {
 tokenOutBalance = balance;
 }
 }
 
 // Perform the swap // Perform the swap request callback and compute the new balances for 'token in' and 'token out' after the swap
 amountCalculated = pool.onSwap(request, currentBalances, indexIn, indexOut);
 (uint256 amountIn, uint256 amountOut) = _getAmounts(request.kind, request.amount, amountCalculated);
 tokenInBalance = tokenInBalance.increaseCash(amountIn);
 tokenOutBalance = tokenOutBalance.decreaseCash(amountOut);

 // Update storage // Because no tokens were registered or deregistered between now or when we retrieved the indexes for
 // 'token in' and 'token out', we can use `unchecked_setAt` to save storage reads.
 poolBalances.unchecked_setAt(indexIn, tokenInBalance);
 poolBalances.unchecked_setAt(indexOut, tokenOutBalance);
 }

Analyzing the code above, although Vault creates a new currentBalances array every time onSwap is called, in Batch Swap :

  1. After the first exchange, the balance is updated (but the updated value may be inaccurate due to loss of precision).
  2. The second swap continues the calculation based on the result of the first swap.
  3. Accumulated loss of precision eventually leads to a significant decrease in the invariant value D.

Key issues:

 // BaseGeneralPool._swapGivenIn
 function _swapGivenIn(
 SwapRequest memory swapRequest,
 uint256[] memory balances,
 uint256 indexIn,
 uint256 indexOut,
 uint256[] memory scalingFactors
 ) internal virtual returns (uint256) {
 // Fees are subtracted before scaling, to reduce the complexity of the rounding direction analysis.
 swapRequest.amount = _subtractSwapFeeAmount(swapRequest.amount);

 _upscaleArray(balances, scalingFactors); // Modify the array in place. swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);

 uint256 amountOut = _onSwapGivenIn(swapRequest, balances, indexIn, indexOut);

 // amountOut tokens are exiting the Pool, so we round down.
 return _downscaleDown(amountOut, scalingFactors[indexOut]);
 }
// Although Vault passes in a new array each time, but:
// 1. If the balance is very small (8-9 wei), the precision loss during scaling is significant. // 2. In Batch Swap, subsequent swaps continue calculations based on the balance that has already lost precision. // 3. It was not verified whether the change in the invariant value D was within a reasonable range.

Summarize

The reasons for Balancer's attack can be summarized as follows:

1. Scaling function uses rounding down : _upscaleArray uses mulDown for scaling, which will produce a significant loss of relative precision when the balance is very small (such as 8-9 wei).

2. Invariant value calculation is sensitive to precision : The calculation of the invariant value D depends on the scaled balances array, and the precision loss will be directly passed to the calculation of D, making D smaller.

3. Lack of verification of changes in invariant values : During the exchange process, it was not verified whether the changes in the invariant value D were within a reasonable range, which allowed attackers to repeatedly exploit the loss of precision to lower the price of BPT.

4. Accumulated precision loss in batch swap : In the same batch swap, the precision loss from multiple swaps will accumulate and eventually amplify into huge financial losses.

These two issues—precision loss and lack of validation—combined with the attacker's careful design of boundary conditions, resulted in this loss.

Balancer
Welcome to Join Odaily Official Community
AI Summary
Back to Top
  • 核心观点:Balancer因精度损失与不变值操控漏洞遭攻击。
  • 关键要素:
    1. 小额交易缩放函数向下舍入致精度损失。
    2. 不变值D计算依赖缩放后余额,误差放大。
    3. 缺少不变值变化验证与批处理误差累积。
  • 市场影响:引发DeFi协议安全机制与审计标准反思。
  • 时效性标注:中期影响
Download Odaily App
Let Some People Understand Web3.0 First
IOS
Android