Balancerがハッキングされ、脆弱性分析
- 核心观点:Balancer因精度损失与不变值操控漏洞遭攻击。
- 关键要素:
- 小额交易缩放函数向下舍入致精度损失。
- 不变值D计算依赖缩放后余额,误差放大。
- 缺少不变值变化验证与批处理误差累积。
- 市场影响:引发DeFi协议安全机制与审计标准反思。
- 时效性标注:中期影响
序文
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 つの問題と、攻撃者が境界条件を慎重に設計したことが相まって、この損失が発生しました。


