Home - Coinspect Security
balancer stable rate manipulation

Balancer V2 Stable Pools Exploit — Rate Manipulation

Security Engineer
DeFi, Ethereum, Exploit, Technical Writeup

An attacker manipulated exchange rates in Balancer V2 stable pools by issuing a long, alternating batch‑swap sequence that exploited rounding in the stable invariant. The sequence produced an underestimation of the invariant D, which reduced the implied BPT price. Proceeds accumulated as internal balances and were withdrawn in a subsequent transaction. At the time of writing, stolen funds attributed to this attacker exceed USD 128 million.

At‑a‑glance:

  • Single‑call batchSwap targeting different pairs.
  • Down‑rounding in the stable invariant biased D downward.
  • Internal balances were withdrawn in a second transaction.

Architecture

In Balancer V2, all assets reside in the Vault while pricing logic is implemented inside pool contracts. Swaps are routed through the Vault. The system supports multi‑step execution via batchSwap, and stable pools apply per‑token scaling. The stable math uses down‑rounding operations (divDown, mulDown) on scaled quantities during invariant computation.

Observed Effects

Two pools showed large movements between BPT and underlying tokens:

These changes align with an underestimation of the invariant within the manipulation window.

Root Cause

The stable swap invariant is computed over scaled balances with repeated down‑rounding. Under certain balance configurations, these rounding steps accumulate and bias the estimate of D downward. Because BPT price scales with D / totalSupply, a lower D yields a lower implied BPT price than the balances warrant. Mixed token decimals amplify the precision loss. A multi‑step batchSwap allows the attacker to maintain balances near rounding boundaries within a single call, preserving the bias long enough to extract value.

// StableMath.sol - Invariant calculation with divDown
D_P = Math.divDown(Math.mul(D_P, invariant), Math.mul(balances[j], numTokens));
invariant = Math.divDown(...);  // Multiple divDown operations compound precision loss

Method

The attacker deployed two contracts: a coordinator that orchestrated pool discovery and swaps, and a math helper that evaluated candidate parameters on‑chain. The coordinator queried the pool state from the Vault, invoked the helper with candidate inputs, recorded results and reverts, and used those signals to assemble a single batchSwap with an alternating index pattern. After the manipulation, the coordinator executed a separate withdrawal step to move accumulated internal balances out of the Vault.

Components

  • SC1 - Coordinator (0x54b53…a30d): Orchestrates the attack. Reads getPoolTokens, identifies indices (BPT, WETH, other), runs parameter probes, builds BatchSwapStep[], submits batchSwap, and later calls manageUserBalance to extract value from Balancer.
  • SC2 - Math helper (0x679b3…381e): Computes stable‑invariant‑related expressions over scaled balances. Edge inputs drive denominators toward zero; reverts such as BAL#004 (division by zero) mark boundaries.

Attack Steps

  1. Boundary search (binary feedback): SC1 iterated over inputs (balance deltas, scaling/amount candidates) and called SC2. When SC2 completed, SC1 kept the candidate; when SC2 targeted division by zero errors, SC1 treated it as a boundary signal and adjusted inputs. This performed a binary search over regions where rounding effects are largest, with reverts that could indicate proximity to division edges.

Screenshot showing the binary search steps in the transaction traces

  1. Rate manipulation: Using the best candidates, SC1 constructed one long batchSwap. The steps alternated indices in a 4‑leg block, indicating swaps in circular directions. Input amounts were chosen to keep balances near rounding thresholds so that repeated down‑rounding in Stable math underestimated D within the call.

Screenshot showing the multiple chained batch swaps in the transaction traces

  1. Value extraction (separate call): With internal balances credited from the first call, SC1 invoked manageUserBalance(WITHDRAW_INTERNAL) for each asset, then performed ERC‑20 transfers to the recipient account.

On-Chain Evidence

Transactions:

Decompiled Math Helper

The attacker’s math helper exposes a single entrypoint at selector 0x524c9e20. The function operates on scaled balances and pool parameters to search for inputs that push stable invariant denominators toward zero. Reverts with Balancer math error codes (BAL#004, division by zero) act as binary feedback for the coordinator’s boundary search. The code below is a decompilation of the bytecode seen on-chain.

/**
 * @notice Main exploit function - selector 0x524c9e20
 * 
 * The function performs complex calculations to find values that cause
 * division by zero in Balancer's math, which triggers BAL#004 errors
 * that serve as binary search feedback
 *
 * @param scalingFactors Array of token scaling factors
 * @param balances Current token balances in pool
 * @param indexIn Index of token going into pool
 * @param indexOut Index of token coming out of pool
 * @param amountGiven Amount being manipulated
 * @param normalizedWeight Pool weight parameter
 * @param swapFeePercentage Swap fee in basis points
 */
function fn_0x524c9e20(
    uint256[] calldata scalingFactors,
    uint256[] calldata balances,
    uint256 indexIn,
    uint256 indexOut,
    uint256 amountGiven,
    uint256 normalizedWeight,
    uint256 swapFeePercentage
) external onlyAuthorized returns (uint256) {
    // Step 1: Scale balances according to scaling factors
    uint256[] memory adjustedBalances = new uint256[](scalingFactors.length);
    for (uint256 i = 0; i < scalingFactors.length; i++) {
        adjustedBalances[i] = (balances[i] * scalingFactors[i]) / PRECISION;
    }
    
    // Step 2: Calculate the manipulation amount
    // This matches the complex calculation at label_016A
    uint256 manipulationAmount = (amountGiven * balances[indexOut]) / PRECISION;
    
    // Step 3: Calculate invariant ratio (matching func_0297 logic)
    uint256 invariantRatio = _calculateInvariantRatio(
        normalizedWeight,
        adjustedBalances
    );
    
    // Step 4: Update the adjusted balance at indexOut
    // This is the key manipulation that can cause division issues
    uint256 adjustedAmount = _sub(adjustedBalances[indexOut], manipulationAmount);
    adjustedBalances[indexOut] = adjustedAmount;
    
    // Step 5: Calculation section (matching labels 0x0422-0x0675)
    uint256 weightedProduct = normalizedWeight * adjustedBalances.length;
    
    // Calculate initial values
    uint256 sum1 = adjustedBalances[0];
    uint256 product1 = adjustedBalances[0] * adjustedBalances.length;
    
    // Loop through remaining balances (matching label_059E loop)
    for (uint256 i = 1; i < adjustedBalances.length; i++) {
        product1 = _mulDiv(product1, adjustedBalances[i], adjustedBalances.length);
        sum1 = _add(sum1, adjustedBalances[i]);
    }
    
    // Subtract the output balance (matching label_05E8)
    sum1 = _sub(sum1, adjustedBalances[indexOut]);
    
    // Relevant calculations that can trigger BAL#004
    uint256 denominator1 = _mulDiv(invariantRatio, invariantRatio, BASIS_POINTS);
    uint256 numerator1 = _divUp(
        _mul(denominator1, weightedProduct),
        _add(product1, BASIS_POINTS)
    );
    
    uint256 denominator2 = _divUp(
        _mul(invariantRatio, weightedProduct),
        BASIS_POINTS
    );
    uint256 finalSum = _add(sum1, denominator2);
    
    // Key Step: Create conditions for zero division
    // This calculation can result in zero under specific conditions
    uint256 criticalValue = _add(denominator1, numerator1);
    uint256 finalDenominator = _divUp(
        _add(criticalValue, finalSum),
        _add(invariantRatio, normalizedWeight)
    );
    
    // This is where BAL#004 can be triggered
    // If finalDenominator becomes zero, Balancer will revert with BAL#004
    if (finalDenominator == 0) {
        _revertWithBalancerError(4); // BAL#004
    }
    
    // Return the result (may not be reached if revert occurs)
    return _div(numerator1, finalDenominator);
}

The SC2 provides a measurable objective for SC1’s search: maximize rounding bias without triggering a revert. Reverts delineate unsafe regions. SC1 uses these signals to size the per‑step amount values in the manipulation call.

Conclusion

The attack demonstrates how edge cases in DeFi protocols can be systematically discovered and exploited. The vulnerability stemmed from the accumulation of rounding errors when token balances were manipulated to specific values. By setting balances to rounding boundaries and executing calculated swaps, the attacker could deflate `BPT` prices and extract value through arbitrage.

This incident highlights that:
- Mathematical models must consider precision loss at edge cases
- Consistent rounding directions can create exploitable biases, amplified when used in operations involving big numbers
- Attackers are developing and getting better in their exploitation techniques