前言
2025 年11 月3 日,Balancer 協定在Arbitrum、Ethereum 等多條公鏈遭受駭客攻擊,造成1.2 億美元資產損失,攻擊核心源自於精確度損失與不變值(Invariant)操控的雙重漏洞。
本次攻擊的關鍵問題出在協議處理小額交易的邏輯上。當使用者進行小金額交換時,協定會呼叫_upscaleArray函數,該函數使用mulDown進行數值向下捨去。一旦交易中的餘額與輸入金額同時處於特定舍入邊界(例如8-9 wei 區間),就會產生明顯的相對精度誤差。
精度誤差傳遞到協定的不變值D 的計算過程中,導致D 值被異常縮小。而D 值的變動會直接拉低Balancer 協議中的BPT(Balancer Pool Token)價格,駭客利用這一被壓低的BPT 價格,透過預先設計的交易路徑完成套利,最終造成巨額資產損失。
漏洞攻擊Tx: https://etherscan.io/tx/0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742
資產轉移Tx:https://etherscan.io/tx/0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569
技術分析
攻擊入口
攻擊的入口為Balancer: Vault合約,對應的入口函數為batchSwap函數,內部調用onSwap做代幣兌換。
 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);
 }
從函數參數和限制來看,可以得到幾個資訊:
- 攻擊者需要透過Vault 呼叫這個函數,無法直接呼叫。
 - 函數內部會呼叫
_scalingFactors()取得縮放因子進行縮放操作。 - 縮放操作集中在
_swapGivenIn或_swapGivenOut中。 
攻擊模式分析
BPT Price 的電腦制
在Balancer 的穩定池模型中, BPT 價格是重要的參考依據,能決定使用者得到多少BPT 和每個BPT 得到多少資產。
BPT 價格= D / totalSupply 其中D = 不變值(Invariant),來自Curve 的StableSwap 模型
在池的交換計算中:
 // 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, // 使用舊的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);
 }
其中充當BPT 價格基準的部分為不變值D ,也就是操控BPT 價格需要操控D。往下分析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 是縮放後的值}
 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
 // 迭代計算D...
 // D 的計算影響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);
 }
上述程式碼中, D 的計算過程依賴縮放後的balances 陣列。也就是說需要有一個操作來改變這些balances 的精確度,導致D 計算錯誤。
精準度損失的根源
// 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); // 關鍵:放大餘額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]);
 }
縮放操作:
 // 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]); // 向下捨去}
}
// 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; // 向下舍入:直接截斷}
如上在通過_upscaleArray時,如果餘額很小(如8-9 wei), mulDown的向下舍入會導致顯著的精度損失。
攻擊流程詳解
階段1:調整到舍入邊界
攻擊者: BPT → cbETH 目標: 使cbETH 餘額調整到捨去邊界(如末位是9) 假設初始狀態: cbETH 餘額(原): ...000000000009 wei (末位是9)
階段2:觸發精度損失(核心漏洞)
攻擊者: wstETH (8 wei) → cbETH 縮放前: cbETH 餘額: ...000000000009 wei wstETH 輸入: 8 wei 執行_upscaleArray: // cbETH 縮放: 9 * 1e18 / 1e18 = 9 // 但如果實際值是9.5,由於向下捨去變成9 scaled_cbETH = floor(9.5) = 9 精度損失: 0.5 / 9.5 = 5.3% 的相對誤差計算交換: 輸入(wstETH): 8 wei (縮放後) 餘額(cbETH): 9 (錯誤,應該是9.5) 由於cbETH 被低估,計算出的新餘額也會被低估導致D 計算錯誤: D_original = f(9.5, ...) D_new = f(9, ...) < D_original
階段3:利用被壓低的BPT 價格獲利
攻擊者: 底層資產→ BPT 此時: D_new = D_original - ΔD BPT 價格= D_new / totalSupply < D_original / totalSupply 攻擊者用較少的底層資產換得相同數量的BPT 或用相同的底層資產換得更多的BPT
如上攻擊者透過Batch Swap在一個交易中執行多次兌換:
- 第一次交換:BPT → cbETH(調整餘額)
 - 第二交換:wstETH (8) → cbETH(觸發精確度損失)
 - 第三次交換:底層資產→ BPT(獲利)
 
這些交換都在同一個batch swap 交易中,共享相同的餘額狀態,但每次交換都會呼叫_upscaleArray修改balances 陣列。
Callback 機制的缺失
主流程是Vault 開啟的,是怎麼導致精度損失累積的呢?答案在balances 數組的傳遞機制中。
 // Vault 呼叫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.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(); // 從儲存讀取request.lastChangeBlock = Math.max(request.lastChangeBlock, balance.lastChangeBlock());
 if (i == indexIn) {
 tokenInBalance = balance;
 } else if (i == indexOut) {
 tokenOutBalance = balance;
 }
 }
 
 // 執行交換// 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);
 // 更新儲存// 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);
 }
分析如上程式碼,雖然在每次呼叫onSwap時Vault 都會建立新的currentBalances數組,但在Batch Swap :
- 第一次交換後,餘額被更新(但由於精確度損失,更新後的值可能不準確)
 - 第二次交換是基於第一次的結果繼續計算
 - 精度損失累積,最終導致不變值D 顯著變小
 
關鍵問題:
 // 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); // 原地修改數組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]);
 }
// 雖然Vault 每次傳入新數組,但:
// 1. 如果餘額很小(8-9 wei),縮放時精度損失大// 2. 在Batch Swap 中,後續交換基於已損失精度的餘額繼續計算// 3. 沒有驗證不變值D 的變化是否在合理範圍內總結
Balancer 的這次攻擊,總結為下面幾個原因:
 1.縮放函數使用向下舍入: _upscaleArray使用mulDown進行縮放,當餘額很小時(如8-9 wei),會產生顯著的相對精度損失。
2.不變值計算對精度敏感:不變值D 的計算依賴縮放後的balances 數組,精度損失會直接傳遞到D 的計算中,使D 變小。
3.缺少不變值變化驗證:在交換過程中,沒有驗證不變值D 的變化是否在合理範圍內,導致攻擊者可以重複利用精度損失壓低BPT 價格。
4. Batch Swap 中的精度損失累積:在同一個batch swap 中,多次交換的精度損失會累積,最終放大為巨大的財務損失。
這兩個問題精度損失+ 缺少驗證,結合攻擊者對邊界條件的精心設計,造成了這次損失。
- 核心观点:Balancer因精度损失与不变值操控漏洞遭攻击。
 - 关键要素:
- 小额交易缩放函数向下舍入致精度损失。
 - 不变值D计算依赖缩放后余额,误差放大。
 - 缺少不变值变化验证与批处理误差累积。
 
 - 市场影响:引发DeFi协议安全机制与审计标准反思。
 - 时效性标注:中期影响
 


