Balancer 被盗 1.2 亿美元漏洞技术分析

互联网 阅读 27 2025-11-03 22:30:06

作者:ExVul Security

 

前言

2025 11 3 日,Balancer 协议在 ArbitrumEthereum 等多条公链遭受黑客攻击,造成 1.2 亿美元资产损失,攻击核心源于精度损失与不变值(Invariant)操控的双重漏洞。

本次攻击的关键问题出在协议处理小额交易的逻辑上。当用户进行小金额交换时,协议会调用_upscaleArray函数,该函数使用mulDown进行数值向下舍入。一旦交易中的余额与输入金额同时处于特定舍入边界(例如 8-9 wei 区间),就会产生明显的相对精度误差。

精度误差传递到协议的不变值 D 的计算过程中,导致 D 值被异常缩小。而 D 值的变动会直接拉低 Balancer 协议中的 BPTBalancer 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中。

攻击模式分析

BPT Price 的计算机制

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 < 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 计算错误。

精度损失的根源

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 < 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:调整到舍入边界

Plain Text
攻击者: BPT → cbETH
目标: 使 cbETH 余额调整到舍入边界(如末位是 9

假设初始状态:
cbETH
余额(原始): ...000000000009 wei (末位是 9)

阶段 2:触发精度损失(核心漏洞)

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

阶段 3:利用被压低的 BPT 价格获利

Plain Text
攻击者: 底层资产 → BPT

此时:
D_new = D_original - ΔD
BPT
价格 = D_new / totalSupply < D_original / 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 < 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 中:

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 中,多次交换的精度损失会累积,最终放大为巨大的财务损失。

这两个问题精度损失 缺少验证,结合攻击者对边界条件的精心设计,造成了这次损失。

免责声明:
1.资讯内容不构成投资建议,投资者应独立决策并自行承担风险
2.本文版权归属原作所有,仅代表作者本人观点,不代表本站的观点或立场
上一篇:GMPayer:面向 AI Agents 的跨链 x402 支付枢纽 下一篇:美联储的“鹰派降息”与加密市场的获利了结 Hotcoin Research | 2025 年 10 月 27 日-31 日

您可能感兴趣