icon_install_ios_web icon_install_ios_web icon_install_android_web

Balancer hacked, vulnerability analysis

Analysis3hrs agoUpdate Wyatt
191 0

Foreword

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  prevInvariant) {
 if (invariant - prevInvariant 

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 

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, ...) 

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 

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 

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.

This article is sourced from the internet: Balancer hacked, vulnerability analysisRecommended Articles

Related: BitMart Launches Pre-Market Trading, with Monad (MON) as the First Project Launched

To further enrich its trading product portfolio and enhance user engagement and asset allocation flexibility, BitMart has officially launched a new feature: pre-market trading . This innovative mechanism provides users with the opportunity to invest in projects before their official launch, helping them capture early value more efficiently and gain direct access to high-quality assets. Pre-market trading: an innovative pre-release token trading mechanism Pre-market trading is an innovative financial tool launched by BitMart based on a staking mechanism. Users can mint PreTokens by staking USDT and trade them freely in a dedicated pre-market spot market. The core logic of this model is that before the project token is officially launched, PreToken provides users with a channel for early participation, thereby achieving pre-positioning and price discovery of potential assets. Key features…

© Copyright Notice

Related articles