风险提示:防范以"数字货币""区块链"名义进行非法集资的风险
作者:ExVul Security

全球三大交易所之一,注册领50U数币盲盒,币圈常用的交易平台!

币安是世界领先的数字货币交易平台,注册领100U。
前言
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()); return |
从函数参数和限制来看,可以得到几个信息:
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. uint256 finalBalanceOut = _getTokenBalanceGivenInvariantAndAllOtherBalances( tokenIndexOut ); // No need to use checked arithmetic since `tokenAmountIn` was actually added to the same balance right before 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 } if (sum == 0) { return 0; } uint256 prevInvariant; // Dprev 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 ) { prevInvariant = invariant; invariant = Math.divDown( if (invariant > prevInvariant) { _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. |
缩放操作:
| 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) { } } // FixedPoint.mulDown 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. if (indexIn == 0 || indexOut == 0) { // EnumerableMap stores indices *plus one* to use the zero index as a sentinel value – because these are valid, uint256 tokenAmount = poolBalances.length(); request.lastChangeBlock = 0; currentBalances[i] = balance.total(); // 从存储读取request.lastChangeBlock = Math.max(request.lastChangeBlock, balance.lastChangeBlock()); if (i == indexIn) { // 执行交换// 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. // 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 中,多次交换的精度损失会累积,最终放大为巨大的财务损失。
这两个问题精度损失 缺少验证,结合攻击者对边界条件的精心设计,造成了这次损失。
温馨提示:仅提供区块链&数字货币平台信息分享服务,所有产品及展示信息均来源于发行方或者互联网。炒币属于投资行为,不等同于银行存款。市场有风险,投资需谨慎。投资虚拟货币有极大的风险,本网站提供的任何信息都不构成投资建议、财务咨询、交易咨询,或任何其他建议的依据,领域OK并不推荐您购买、售出或持有任何虚拟货币。在做出任何投资决定前,请先充分衡量风险。如有损失,请自行承担后果。




