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 中,多次交换的精度损失会累积,最终放大为巨大的财务损失。
这两个问题精度损失 + 缺少验证,结合攻击者对边界条件的精心设计,造成了这次损失。
- 1比特币2025年关键支撑与阻力分析:投资者如何制定战略路线图
 - 22025年最值得关注的七大高潜力加密货币:小额投入或可带来巨大回报
 - 3中美贸易协议与降息预期或成市场上涨新引擎
 - 4BTC美元价格迎FOMC会议:Polymarket预测降息,鲍威尔能否兑现市场期待?
 - 5首位进入太空的比特币开发者批评BIP-444:“对比特币的一次攻击”
 - 6比特币成为企业财务战略核心:2025年对冲工具与机构采用加速
 - 7Strategy成为首家获标普全球评级的比特币财务公司,信用评级为B-
 - 8分析师预测比特币将重返崩盘前水平,价格飙升至2500万美元以上
 - 9FOMC会议前夕:比特币、以太坊和XRP价格走势分析与预测
 
- 交易所
 - 币种
 
| 排名 | 交易所 | 成交额 | 
|---|---|---|
| 1 |  币安网 | 
		     ¥1.01万亿 | 
| 2 |  欧易OKX | 
		     ¥3,621.51亿 | 
| 3 |  HTX | 
		     ¥693.02亿 | 
| 4 |  Coinbase | 
		     ¥243.53亿 | 
| 5 |  大门 | 
		     ¥2,527.48亿 | 
| 6 |  Bitget | 
		     ¥1,630.48亿 | 
| 7 |  Bybit | 
		     ¥2,851.29亿 | 
| 8 |  K网(Kraken) | 
		     ¥127.54亿 | 
| 9 |  双子星(Gemini) | 
		     ¥19.41亿 | 
| 10 |  Upbit | 
		     ¥168.44亿 | 

币安网
欧易OKX
HTX
Coinbase
大门
Bitget
Bybit
K网(Kraken)
双子星(Gemini)
Upbit
泰达币
比特币
以太坊
Solana
USD Coin
瑞波币
币安币
First Digital USD
狗狗币
大零币