Balancer被盗,漏洞技术分析
前言
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 D D^(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 = A D 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 中,多次交换的精度损失会累积,最终放大为巨大的财务损失。
这两个问题精度损失 + 缺少验证,结合攻击者对边界条件的精心设计,造成了这次损失。
Bitcoin Whale Wallets Drain Exchanges with $120M BTC Withdrawals in 6 Hours
Today, several Bitcoin whales have transferred huge amounts of BTC out of centralized exchanges in t...
XRP and Toncoin Fall Over 9%, But BullZilla’s $1M Presale Dominates the Top Crypto Presales This Year
XRP slides 5.85% & TON drops 9.77%, but BullZilla’s $1M presale shines as one of the top crypto pres...
Bitcoin Slips Below Crucial Support, Indicates Growing Market Uncertainty
As per the latest data from Glassnode, losing the key support level has increased the risk of furthe...
