Balancer Hacked: Technical Analysis of the Vulnerability

CN
9 hours ago

Preface

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 involving precision loss and manipulation of the invariant value.

The key issue of this attack lies in the protocol's logic for handling small transactions. When users perform small amount swaps, the protocol calls the _upscaleArray function, which uses mulDown for rounding down values. If the balance in the transaction and the input amount are both at specific rounding boundaries (for example, in the 8-9 wei range), it can lead to significant relative precision errors.

This precision error propagates into the calculation of the protocol's invariant value D, causing the D value to be abnormally reduced. The fluctuation of the D value directly lowers the price of BPT (Balancer Pool Token) in the Balancer protocol, and the hacker exploited this depressed BPT price to complete arbitrage through a pre-designed trading path, ultimately resulting in massive asset losses.

Vulnerability attack Tx: https://etherscan.io/tx/0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742

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

Technical Analysis

Attack Entry Point

The entry point of the attack is the Balancer: Vault contract, with the corresponding entry function being the batchSwap function, which internally calls onSwap for token exchanges.

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 constraints, several pieces of information can be gathered:

  1. The attacker needs to call this function through the Vault and cannot call it directly.
  2. The function internally calls _scalingFactors() to obtain scaling factors for scaling operations.
  3. The scaling operations are concentrated in _swapGivenIn or _swapGivenOut.

Attack Mode Analysis

BPT Price Calculation Mechanism

In Balancer's stable pool model, the BPT price is an important reference that determines how much BPT a user receives and how much asset each BPT gets.

BPT Price = D / totalSupply where D = invariant value, derived from Curve's StableSwap model.

In the pool's swap calculation:

// StableMath._calcOutGivenIn function
_calcOutGivenIn(uint256 amplificationParameter, uint256[] memory balances, uint256 tokenIndexIn, uint256 tokenIndexOut, uint256 tokenAmountIn, uint256 invariant) internal pure returns (uint256) {
    // Amount out, so we round down overall.
    balances[tokenIndexIn] = balances[tokenIndexIn].add(tokenAmountIn);
    uint256 finalBalanceOut = _getTokenBalanceGivenInvariantAndAllOtherBalances(amplificationParameter, balances, invariant, tokenIndexOut);
    balances[tokenIndexIn] = balances[tokenIndexIn] - tokenAmountIn;
    return balances[tokenIndexOut].sub(finalBalanceOut).sub(1);
}

The part that acts as the BPT price benchmark is the invariant value D, which means manipulating the BPT price requires manipulating D. Let's analyze the calculation process of D further:

// 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 = A D n^n + ----------- // S = sum 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 are scaled values
}
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

// Iteratively calculate 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 above code, the **calculation process of D depends on the scaled balances array**. This means that an operation is needed to change the precision of these balances, leading to incorrect D calculations.

### **Root Cause of Precision Loss**

solidity // 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 balances 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:**

solidity // 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.MULOVERFLOW); return product / ONE; // Round down: truncate directly }

As shown, when using `_upscaleArray`, if the balances are very small (e.g., 8-9 wei), the rounding down by `mulDown` can lead to significant precision loss.

### **Detailed Attack Process**

#### **Phase 1: Adjust to Rounding Boundary**

Attacker: BPT → cbETH  
Goal: Adjust cbETH balance to the rounding boundary (e.g., last digit is 9)  
Assumed initial state: cbETH balance (original): ...000000000009 wei (last digit is 9)

#### **Phase 2: Trigger Precision Loss (Core Vulnerability)**


Attacker: wstETH (8 wei) → cbETH  
Before scaling: cbETH balance: ...000000000009 wei  
wstETH input: 8 wei  
Executing `_upscaleArray`:  
// cbETH scaling: 9 * 1e18 / 1e18 = 9  
// But if the actual value is 9.5, due to rounding down it becomes 9  
scaled_cbETH = floor(9.5) = 9  
Precision loss: 0.5 / 9.5 = 5.3% relative error  
Calculating swap:  
Input (wstETH): 8 wei (after scaling)  
Balance (cbETH): 9 (incorrect, should be 9.5)  
Since cbETH is underestimated, the newly calculated balance will also be underestimated, leading to incorrect D calculation:  
D_original = f(9.5, ...)  
D_new = f(9, ...)  
D_original

#### **Phase 3: Profit from Depressed BPT Price**

Attacker: Underlying asset → BPT  
At this point: D_new = D_original - ΔD  
BPT price = D_new / totalSupply  
D_original / totalSupply  
The attacker exchanges less underlying assets for the same amount of BPT or exchanges the same underlying assets for more BPT.

As described above, the attacker executes multiple exchanges in one transaction through **Batch Swap**:

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

These swaps all occur in the same batch swap transaction, **sharing the same balance state**, but each swap calls `_upscaleArray` to modify the balances array.

### **Lack of Callback Mechanism**

The main process is initiated by the Vault; how does it lead to the accumulation of precision loss? The answer lies in the **passing mechanism of the balances array**.

solidity // Logic when Vault calls onSwap function _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.uncheckedindexOf(request.tokenIn); uint256 indexOut = poolBalances.uncheckedindexOf(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.TOKENNOTREGISTERED); } // 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; 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; } } // Execute 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.uncheckedsetAt(indexIn, tokenInBalance); poolBalances.uncheckedsetAt(indexOut, tokenOutBalance); } ```

Analyzing the above code, although a new currentBalances array is created each time onSwap is called, in a Batch Swap:

  1. After the first swap, the balance is updated (but due to precision loss, the updated value may be inaccurate).
  2. The second swap continues calculations based on the result of the first.
  3. Precision loss accumulates, ultimately leading to a significant reduction in the invariant value D.

Key Issue:

// 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); // In-place modification of the array 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 the Vault passes a new array each time, // 1. If the balance is very small (8-9 wei), significant relative precision loss occurs during scaling. // 2. In Batch Swap, subsequent swaps continue calculations based on balances that have already lost precision. // 3. There is no verification of whether the change in invariant D is within a reasonable range.

Summary

The attack on Balancer can be summarized by the following reasons:

  1. Scaling function uses rounding down: _upscaleArray uses mulDown for scaling, which results in significant relative precision loss when balances are very small (e.g., 8-9 wei).

  2. Invariant value calculation is sensitive to precision: The calculation of invariant D relies on the scaled balances array, and precision loss directly affects the calculation of D, making it smaller.

  3. Lack of verification for invariant value changes: During the swap process, there was no verification of whether the change in invariant D was within a reasonable range, allowing the attacker to repeatedly exploit precision loss to depress the BPT price.

  4. Accumulation of precision loss in Batch Swap: In the same batch swap, the precision loss from multiple swaps accumulates, ultimately resulting in significant financial losses.

These two issues—precision loss and lack of verification—combined with the attacker's careful design of boundary conditions, led to this loss.

免责声明:本文章仅代表作者个人观点,不代表本平台的立场和观点。本文章仅供信息分享,不构成对任何人的任何投资建议。用户与作者之间的任何争议,与本平台无关。如网页中刊载的文章或图片涉及侵权,请提供相关的权利证明和身份证明发送邮件到support@aicoin.com,本平台相关工作人员将会进行核查。

Share To
APP

X

Telegram

Facebook

Reddit

CopyLink