序文
2025年11月3日、ArbitrumやEthereumなどの複数のパブリックチェーンにおいて、Balancerプロトコルがハッカーによる攻撃を受け、1億2000万ドルの資産が失われました。攻撃の核心は、精度損失と不変操作という二重の脆弱性に起因していました。
この攻撃の鍵となる問題は、プロトコルの小額取引処理ロジックにあります。ユーザーが小額取引を行うと、プロトコルは_upscaleArray関数を呼び出します。この関数はmulDown用いて値を切り捨てます。取引残高と入力金額の両方が特定の切り捨て境界(例えば8~9 weiの範囲)内にある場合、重大な相対精度誤差が発生します。
プロトコルにおける不変値Dの計算精度に誤差が伝播し、Dの値が異常に減少しました。Dの値の変化は、BalancerプロトコルにおけるBPT(Balancer Pool Token)の価格を直接下落させました。ハッカーは、このBPTの価格低下を悪用し、事前に設計されたトランザクションパスを通じて裁定取引を行い、最終的に巨額の資産損失を引き起こしました。
脆弱性攻撃リンク: https://etherscan.io/tx/0x6ed07db1a9fe5c0794d44cd36081d6a6df103fab868cdd75d581e3bd23bc9742
資産移転リンク: https://etherscan.io/tx/0xd155207261712c35fa3d472ed1e51bfcd816e616dd4f517fa5959836f5b48569
テクニカル分析
攻撃の入り口
攻撃のエントリ ポイントはBalancer: Vaultコントラクトであり、対応するエントリ関数はbatchSwap関数です。この関数は内部的にonSwapを呼び出してトークン スワップを実行します。
関数onSwap(
 SwapRequest メモリ swapRequest、
 uint256[] メモリ残高、
 uint256 インデックスIn、
 uint256 インデックス出力
 ) 外部オーバーライドのみVault(swapRequest.poolId) は (uint256) {を返します
 _beforeSwapJoinExit();
 _validateIndexes(indexIn、indexOut、_getTotalTokens());
 uint256[] メモリ scalingFactors = _scalingFactors();
 戻る
 swapRequest.kind == IVault.SwapKind.GIVEN_IN
 ? _swapGivenIn(swapRequest, 残高, indexIn, indexOut, スケーリング係数)
 : _swapGivenOut(swapRequest、残高、indexIn、indexOut、scalingFactors);
 }
関数のパラメータと制限から、いくつかの情報を取得できます。
- 攻撃者はこの関数を Vault 経由で呼び出す必要があります。直接呼び出すことはできません。
 - この関数は内部的に
_scalingFactors()を呼び出して、スケーリング操作のスケーリング係数を取得します。 - スケーリング操作は、 
_swapGivenInまたは_swapGivenOutのいずれかで処理されます。 
攻撃パターン分析
BPT価格計算方法
Balancer の安定したプール モデルでは、 BPT の価格は重要な参照ポイントであり、これによってユーザーが受け取る BPT の数と、BPT ごとに受け取られる資産の数が決まります。
BPT価格 = D / 総供給量 ここで、D は不変であり、Curve の StableSwap モデルから来ています。
プール交換計算では、
 // StableMath._calcOutGivenIn
 関数_calcOutGivenIn(
 uint256 増幅パラメータ、
 uint256[] メモリ残高、
 uint256 トークンインデックスイン、
 uint256 トークンインデックス出力、
 uint256 トークン金額、
 uint256不変
 ) 内部純粋戻り値 (uint256) {
 /******************************************************************************************************************
 // outGivenInトークンx for y - 解くべき多項式 //
 // ay = 計算する金額 //
 // by = 残高トークン出力 //
 // y = by - ay (finalBalanceOut) //
 // D = 不変 DD^(n+1) //
 // A = 増幅係数 y^2 + ( S + ---------- - D) * y - ------------- = 0 //
 // n = トークンの数 (A * n^n) A * n^2n * P //
 // S = 最終残高の合計ですがy //
 // P = 最終残高の積だが y //
 ******************************************************************************************************************/
 // 金額が足りないので、全体を切り捨てます。
 残高[トークンインデックスIn] = 残高[トークンインデックスIn].add(トークン金額In);
 uint256 finalBalanceOut = _getTokenBalanceGivenInvariantAndAllOtherBalances(
 増幅パラメータ、
 残高
 不変、// 古いDを使用
 トークンインデックスアウト
 );
 // `tokenAmountIn` は実際には直前に同じ残高に追加されているので、チェックされた算術演算を使用する必要はありません。
 // 残高配列を変更しない `_getTokenBalanceGivenInvariantAndAllOtherBalances` を呼び出します。
 残高[トークンインデックスIn] = 残高[トークンインデックスIn] - トークン金額In;
 残高[tokenIndexOut].sub(finalBalanceOut).sub(1);を返します。
 }
BPT価格のベンチマークとなる部分は定数値Dです。つまり、BPT価格を操作するにはDを操作する必要があります。Dの計算プロセスを分析してみましょう。
 // StableMath._calculateInvariant
 関数_calculateInvariant(uint256 増幅パラメータ、uint256[] メモリ残高)
 内部
 純粋な
 戻り値 (uint256)
 {
 /******************************************************************************************************
 // 不変 //
 // D = 不変 D^(n+1) //
 // A = 増幅係数 A n^n S + D = AD n^n + ----------- //
 // S = 残高の合計 n^n P //
 // P = 残高の積 //
 // n = トークンの数 //
 ******************************************************************************************************/
 // Vyper の演算 (常に切り捨て) と一致するように、常に切り捨てます。
 uint256 sum = 0; // 曲線バージョンのS
 uint256 numTokens = 残高.長さ;
 (uint256 i = 0; i < numTokens; i++) の場合 {
 sum = sum.add(balances[i]); // balancesはスケールされた値です}
 合計 == 0 の場合
 0を返します。
 }
 uint256 prevInvariant; // CurveバージョンのDprev
 uint256 invariant = sum; // 曲線バージョンのD
 uint256 ampTimesTotal = accelerationParameter * numTokens; // カーブバージョンのAnn
 // D の反復計算...
 // Dの計算は残高の精度に影響します for (uint256 i = 0; i < 255; i++) {
 uint256 D_P = 不変;
 (uint256 j = 0; j < numTokens; j++) の場合 {
 // (D_P * 不変) / (残高[j] * トークン数)
 D_P = Math.divDown(Math.mul(D_P, 不変), Math.mul(残高[j], トークン数));
 }
 prevInvariant = 不変;
 不変 = Math.divDown(
 数学.mul(
 // (ampTimesTotal * 合計) / AMP_PRECISION + D_P * トークン数
 (Math.divDown(Math.mul(ampTimesTotal, sum), _AMP_PRECISION).add(Math.mul(D_P, numTokens))),
 不変
 )、
 // ((ampTimesTotal - _AMP_PRECISION) * 不変) / _AMP_PRECISION + (numTokens + 1) * D_P
 (
 Math.divDown(Math.mul((ampTimesTotal - _AMP_PRECISION), 不変), _AMP_PRECISION).add(
 Math.mul((トークン数 + 1), D_P)
 )
 )
 );
 if (不変> prev不変) {
 if (不変 - prevInvariant <= 1) {
 不変値を返します。
 }
 } そうでなければ (前の不変量 - 不変量 <= 1) {
 不変値を返します。
 }
 }
 _revert(Errors.STABLE_INVARIANT_DIDNT_CONVERGE);
 }
上記のコードでは、 Dの計算はスケールされた残高配列に依存しています。つまり、これらの残高の精度を変更する操作が必要になり、Dの計算でエラーが発生します。
精度低下の根本原因
// BaseGeneralPool._swapGivenIn
 関数_swapGivenIn(
 SwapRequest メモリ swapRequest、
 uint256[] メモリ残高、
 uint256 インデックスIn、
 uint256 インデックスアウト、
 uint256[] メモリスケーリング係数
 ) 内部仮想戻り値 (uint256) {
 // 丸め方向の分析の複雑さを軽減するために、スケーリングの前に手数料が差し引かれます。
 swapRequest.amount = _subtractSwapFeeAmount(swapRequest.amount);
 _upscaleArray(balances, scalingFactors); // キー: 残高をアップスケールする swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);
 uint256 amountOut = _onSwapGivenIn(swapRequest、残高、indexIn、indexOut);
 // amountOut トークンはプールから出ていくため、切り捨てられます。
 _downscaleDown(amountOut、scalingFactors[indexOut]) を返します。
 }
スケーリング操作:
 // スケーリングヘルパー.sol
function _upscaleArray(uint256[] メモリ量, uint256[] メモリスケーリング係数) pure {
 uint256 長さ = 金額.長さ;
 InputHelpers.ensureInputLengthMatch(長さ、スケーリング係数.長さ);
 (uint256 i = 0; i < 長さ; ++i) の場合 {
 amount[i] = FixedPoint.mulDown(amounts[i], scalingFactors[i]); // 切り捨て}
}
// 固定点.mulDown
 関数 mulDown(uint256 a, uint256 b) 内部純粋戻り値 (uint256) {
 uint256 積 = a * b;
 _require(a == 0 || 製品 / a == b、Errors.MUL_OVERFLOW);
 return product / ONE; // 切り捨て: 直接切り捨て}
上記のように、 _upscaleArray使用する場合、残高が非常に小さい場合 (たとえば、8-9 wei)、 mulDownの切り捨てによって精度が大幅に低下します。
攻撃プロセスの詳細
フェーズ1: 丸め境界に調整する
攻撃者: BPT → cbETH 目的: cbETH の残高を四捨五入の境界 (例: 9 で終わる) に調整します。 初期状態を仮定します。 cbETH 残高(オリジナル): ...00000000009 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_オリジナル = 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と交換する
上記の攻撃者は、バッチ スワップを使用して、単一のトランザクション内で複数のスワップを実行しました。
- 最初の交換:BPT → cbETH(残高調整)
 - 2回目のスワップ: wstETH (8) → cbETH (精度損失を発生)
 - 3回目の交換:原資産 → BPT(利益)
 
これらのスワップはすべて同じバッチ スワップ トランザクション内にあり、同じ残高状態を共有しますが、 _upscaleArray各スワップの残高配列を変更するために呼び出されます。
コールバック機構の欠如
メインプロセスは Vault によって開始されますが、これがどのようにして精度損失の蓄積につながるのでしょうか。その答えは、 balances 配列の受け渡しメカニズムにあります。
 // Vault が onSwap を呼び出すときのロジック関数: _processGeneralPoolSwapRequest(IPoolSwapStructs.SwapRequest メモリ要求、IGeneralPool プール)
 プライベート
 (uint256 amountCalculated)を返します
 {
 バイト32 トークン残高;
 バイト32 トークンアウトバランス;
 // 両方のトークン インデックスにアクセスしますが、存在を確認せずにアクセスします。これは、直後に手動で行うためです。
 EnumerableMap.IERC20ToBytes32Map ストレージ プールバランス = _generalPoolsBalances[request.poolId];
 uint256 indexIn = poolBalances.unchecked_indexOf(request.tokenIn);
 uint256 indexOut = poolBalances.unchecked_indexOf(request.tokenOut);
 インデックス入力 == 0 || インデックス出力 == 0 の場合 {
 // プール自体が登録されていないため、トークンが登録されていない可能性があります。これを確認すると、
 // より正確な元に戻す理由。
 _ensureRegisteredPool(request.poolId);
 _revert(Errors.TOKEN_NOT_REGISTERED);
 }
 // EnumerableMapは、ゼロインデックスをセンチネル値として使用するために、インデックスに*プラス1*を格納します。これは有効であるため、
 これを元に戻すことができます。
 インデックスイン -= 1;
 インデックス出力 -= 1;
 uint256 トークン金額 = poolBalances.length();
 uint256[] メモリ currentBalances = new uint256[](tokenAmount);
 リクエスト.lastChangeBlock = 0;
 (uint256 i = 0; i < tokenAmount; i++) の場合 {
 // 反復処理は `tokenAmount` によって制限されており、ここではトークンが登録または登録解除されないため、
 // `i` が有効なトークン インデックスであることを認識し、`unchecked_valueAt` を使用してストレージの読み取りを節約できます。
 bytes32 バランス = poolBalances.unchecked_valueAt(i);
 currentBalances[i] = balance.total(); // ストレージから読み取り request.lastChangeBlock = Math.max(request.lastChangeBlock, balance.lastChangeBlock());
 if (i == インデックスIn) {
 tokenInBalance = 残高;
 } そうでない場合 (i == indexOut) {
 tokenOutBalance = 残高;
 }
 }
 
 // スワップを実行する // スワップ要求コールバックを実行し、スワップ後の「トークンイン」と「トークンアウト」の新しい残高を計算する
 amountCalculated = pool.onSwap(リクエスト、currentBalances、indexIn、indexOut);
 (uint256 amountIn、uint256 amountOut) = _getAmounts(request.kind、request.amount、amountCalculated);
 tokenInBalance = tokenInBalance.increaseCash(amountIn);
 tokenOutBalance = tokenOutBalance.decreaseCash(amountOut);
 // ストレージを更新 // 現在からインデックスを取得したときまでの間にトークンが登録または登録解除されていないため、
 // 'トークン入力' と 'トークン出力' では、`unchecked_setAt` を使用してストレージの読み取りを節約できます。
 poolBalances.unchecked_setAt(インデックスIn、トークンInバランス);
 poolBalances.unchecked_setAt(indexOut、tokenOutBalance);
 }
上記のコードを分析すると、Vault はonSwapが呼び出されるたびに新しいcurrentBalances配列を作成しますが、 Batch Swapでは次のようになります。
- 最初の交換後、残高は更新されます (ただし、精度の低下により更新された値は不正確になる可能性があります)。
 - 2 番目のスワップは、最初のスワップの結果に基づいて計算を続行します。
 - 精度の損失が蓄積されると、最終的には不変値 D が大幅に減少します。
 
主な問題点:
 // BaseGeneralPool._swapGivenIn
 関数_swapGivenIn(
 SwapRequest メモリ swapRequest、
 uint256[] メモリ残高、
 uint256 インデックスIn、
 uint256 インデックスアウト、
 uint256[] メモリスケーリング係数
 ) 内部仮想戻り値 (uint256) {
 // 丸め方向の分析の複雑さを軽減するために、スケーリングの前に手数料が差し引かれます。
 swapRequest.amount = _subtractSwapFeeAmount(swapRequest.amount);
 _upscaleArray(balances, scalingFactors); // 配列をその場で変更します。 swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexIn]);
 uint256 amountOut = _onSwapGivenIn(swapRequest、残高、indexIn、indexOut);
 // amountOut トークンはプールから出ていくため、切り捨てられます。
 _downscaleDown(amountOut、scalingFactors[indexOut]) を返します。
 }
// Vault は毎回新しい配列を渡しますが、
// 1. 残高が非常に小さい場合 (8-9 wei)、スケーリング中に精度の低下が大きくなります。// 2. バッチスワップでは、後続のスワップは、すでに精度が失われた残高に基づいて計算を継続します。// 3. 不変値 D の変化が妥当な範囲内であるかどうかは検証されていません。要約
Balancer の攻撃の理由は次のようにまとめられます。
 1.スケーリング関数は切り捨てを使用します。_upscaleArray _upscaleArrayスケーリングにmulDown使用します。これにより、残高が非常に小さい場合 (8-9 wei など)、相対的な精度が大幅に低下します。
2.不変値の計算は精度に敏感です。不変値 D の計算はスケールされた残高配列に依存し、精度の低下は D の計算に直接渡され、D が小さくなります。
3.不変値の変化の検証不足:交換プロセス中に、不変値Dの変化が妥当な範囲内であるかどうかが検証されなかったため、攻撃者は精度の低下を繰り返し悪用してBPTの価格を下げることができました。
4.バッチスワップにおける累積精度損失: 同じバッチスワップで複数のスワップによる精度損失が累積し、最終的には莫大な経済的損失に増幅されます。
精度の低下と検証の欠如という 2 つの問題と、攻撃者が境界条件を慎重に設計したことが相まって、この損失が発生しました。
- 核心观点:Balancer因精度损失与不变值操控漏洞遭攻击。
 - 关键要素:
- 小额交易缩放函数向下舍入致精度损失。
 - 不变值D计算依赖缩放后余额,误差放大。
 - 缺少不变值变化验证与批处理误差累积。
 
 - 市场影响:引发DeFi协议安全机制与审计标准反思。
 - 时效性标注:中期影响
 


