BTCC / BTCC Square / Btcbaike /
Balancer 被盜 1.2 億美元漏洞技術分析

Balancer 被盜 1.2 億美元漏洞技術分析

Author:
Btcbaike
Published:
2025-11-03 22:30:06
18
2

作者:ExVul Security

 

前言

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做代幣兌換。

Solidity
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);
}

從函數參數和限制來看,可以得到幾個信息:

1.攻擊者需要通過 Vault 調用這個函數,無法直接調用。

2.函數內部會調用_scalingFactors()獲取縮放因子進行縮放操作。

3.縮放操作集中在_swapGivenIn或_swapGivenOut中。

攻擊模式分析

在 Balancer 的穩定池模型中,BPT 價格是重要的參考依據,能決定用戶得到多少 BPT 和每個 BPT 得到多少資產。

Solidity
BPT 價格 = D / totalSupply

其中 D = 不變值(Invariant),來自 Curve 的 StableSwap 模型

在池的交換計算中:

Solidity
// 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 = invariantDD^(n 1)//
// A = amplification coefficienty^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 的計算過程:

Solidity
// StableMath._calculateInvariant
function _calculateInvariant(uint256 amplificationParameter, uint256[] memory balances)
internal
pure
returns (uint256)
{
/**********************************************************************************************
// invariant//
// D = invariantD^(n 1)//
// A = amplification coefficientAn^n S D = A D n^n -----------//
// S = sum of balancesn^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 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 uint256 D_P = invariant;

for (uint256 j = 0; 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 return invariant;
}
} else if (prevInvariant - invariant return invariant;
}
}

_revert(Errors.STABLE_INVARIANT_DIDNT_CONVERGE);
}

上述代碼中,D 的計算過程依賴縮放後的 balances 數組。也就是說需要有一個操作來改變這些 balances 的精度,導致 D 計算錯誤。

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);// 關鍵:放大餘額
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]);
}

縮放操作:

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 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的向下舍入會導致顯著的精度損失。

攻擊流程詳解

Plain Text
攻擊者: BPT → cbETH
目標: 使 cbETH 餘額調整到舍入邊界(如末位是 9)

假設初始狀態:
cbETH 餘額(原始): ...000000000009 wei (末位是 9)

Plain Text
攻擊者: 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, ...)

Plain Text
攻擊者: 底層資產 → BPT

此時:
D_new = D_original - ΔD
BPT 價格 = D_new / totalSupply
攻擊者用較少的底層資產換得相同數量的 BPT
或用相同的底層資產換得更多的 BPT

如上攻擊者通過 Batch Swap 在一個交易中執行多次兌換:

1.第一次交換:BPT → cbETH(調整餘額)

2.第二次交換:WSTETH (8) → cbETH(觸發精度損失)

3.第三次交換:底層資產 → BPT(獲利)

這些交換都在同一個 batch swap 交易中,共享相同的餘額狀態,但每次交換都會調用_upscaleArray修改 balances 數組。

Callback 機制的缺失

主流程是 Vault 開啟的,是怎麼導致精度損失累積的呢?答案在 balances 數組的傳遞機制中。

Solidity
// 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 // 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 中:

1.第一次交換後,餘額被更新(但由於精度損失,更新後的值可能不准確)

2.第二次交換基於第一次的結果繼續計算

3.精度損失累積,最終導致不變值 D 顯著變小

關鍵問題:

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); // 原地修改數組
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 中,多次交換的精度損失會累積,最終放大為巨大的財務損失。

這兩個問題精度損失 缺少驗證,結合攻擊者對邊界條件的精心設計,造成了這次損失。

|Square

下載BTCC APP,您的加密之旅從這啟程

立即行動 掃描 加入我們的 100M+ 用戶行列

本站轉載文章均源自公開網絡平台,僅為傳遞行業信息之目的,不代表BTCC任何官方立場。原創權益均歸屬原作者所有。如發現內容存在版權爭議或侵權嫌疑,請透過[email protected]與我們聯絡,我們將依法及時處理。BTCC不對轉載信息的準確性、時效性或完整性提供任何明示或暗示的保證,亦不承擔因依賴這些信息所產生的任何直接或間接責任。所有內容僅供行業研究參考,不構成任何投資、法律或商業決策建議,BTCC不對任何基於本文內容採取的行為承擔法律責任。