Balancer 128M Exploit Analysis

一、简介

2025年11月3日,攻击者利用 balancer 池不变式计算中的算术精度损失,在不到 30 分钟的时间内,从六个区块链网络中窃取了 1.28 亿美元。 我对这个攻击非常感兴趣,但是现有的网上的文章大多在描述极其有限的技术细节,例如 _upscaleArray 相关逻辑的精度丢失又或者是相邻一两层的调用链,对于尚未了解过 balancer 具体细节的读者不太友好。因此想好好整理一下全部相关细节并趁机学习一下 balancer 协议。

二、Balancer Internal

一句话:Balancer 是以 自动做市商(AMM) 为核心的一个 DeFi 流动性池框架。是不是感觉说了和没说没什么两样,别担心,我们一步步来理解。

1. 什么是 AMM

什么是自动做市商 AMM?我们先了解一下什么是做市商,做市商 Market Maker 是一个用于提供流动性的角色。例如在股票交易中,如果某个标的的买价 bid 和卖价 ask 之间相差巨大,那么这不利于用户进行买卖,因为差价较大会导致交易不到合适的价格进而造成额外的资金损失。而做市商就会通过在订单簿中高频挂单提供流动性,来减少买卖价格的差距。如果你玩过美股期权,那你就尤为能体会到这一点,因为期权的特殊性,其流动性会比较糟糕,因此大部分买卖池里的流动性都是做市商提供的。

而对于加密货币来说,货币转换也会遇到类似的问题。如果用户希望能大额买卖自己的代币,那么需要找到一个能吃下自己所有交易的地方。熟为人知的 Uniswap-V2 就是一个比较经典的地方,它这里持有了大量的 TokenA/TokenB 代币对,并且通过维持 x * y = k 不变量来计算代币兑换价格。例如假如 Uniswap 的 k = 20,000,且当前持有了 1000 USDC 和 20 ETH,则 ETH/USDC 兑换价格为 50。而如果 Uniswap 里持有的代币数量变成了 2000 USDC 和 10 ETH (注意k不变),则 ETH/USDC 兑换价格就变成了 200。你可以看到像 Uniswap 这种交易场所,其交易价格会随着数学公式的计算来自动变换,因此是自动的做市商。自动做市商就是既能自动根据市场情况来变幻价格,又能为用户提供充足的代币流动性来满足交易需求的一个角色

2. Balancer 组件

Balancer-v2 文档里描述了 Balancer 主要由两部分组成:Vault 和 Pools。Vault 和 Pools 是一对多的关系,因此如果用户涉及到多个代币之间的交易操作,则只需要在 Vault 里变动记帐即可,减少重复的转账,降低 gas 费用。

Vault

Vault(pkg/vault/contracts/Vault.sol) 是 Balancer 的核心,它持有管理每个 Balancer 池中的所有代币,也是大多数 Balancer 操作(swaps / joins / exits)的入口。但是需要注意的是 Vault 持有代币和记账,但是它不进行具体的资金管理(例如维护 AMM 不变量等逻辑就不在 vault 里做),这部分逻辑则在 Pools 里进行。

Pools

Balancer 里有很多种不同类型的 pool,这里简单介绍几种比较简单或常用的:

  • Linear Pool:其使用已知且稳定的汇率,让基础资产与其包装后的收益代币进行兑换。例如,在 Aave 中,稳定币 DAI 与将其存入 Aave 后所获得的、代表存款并持续累积利息的 aDAI 之间,就可以通过 Linear Pool 实现高效互换。
  • Weight Pool: 对 Uniswap V1 所推广的经典恒定乘积做市模型(x · y = k)的扩展版本。
  • Composable Stable Pools: 面向一组价值高度相关、预期能以近 1:1 或通过已知汇率实现稳定兑换的资产所设计的流动性池。例如 USDC、USDT、DAI 等稳定币之间的交易,它们的价格波动极小,因此非常适合用低滑点的稳定性曲线进行撮合。

对于 Composable Stable Pools,需要注意的是:

  1. 这类场景与 Linear Pool 的目标并不同:Composable Stable Pool 处理的是“多个相互等价的稳定资产”之间的兑换,而 Linear Pool 处理的是“基础资产与其收益代币”之间的兑换,两者的数学结构和应用场景均有显著差异。
  2. Composable 表示该池的 BPT(Balancer Pool Token,即流动性提供者在向池中存入资产后所收到的 ERC20 份额证明)本身可以作为一种可组合的资产参与其它池子的构建,也就是说池子的 LP 代币能够像普通代币一样被继续嵌套进更高层级的池,从而使不同池子之间能够自由组合、互相引用,并共同形成更大规模、更高资本效率的流动性结构。虽然 Linear Pool 也会将自身的 BPT 注册到 Vault 中,但这些 BPT 可能并不会被当作可组合资产用于构建其他 Pool。

3. Balancer 交互流程

这里我们以 Vault + Composable Stable Pools 组合为例来介绍一下 Balancer 的一些交互流程。

创建 Composable Stable Pools 合约

我们先来看看当创建一个 pool 时,vault 和 pool 之间会有什么样的交互流程以及涉及到的状态变量:

  1. 若想创建一个新的 Composable Stable Pool 时,用户可以自由调用 ComposableStablePoolFactory (pkg/pool-stable/contracts/ComposableStablePoolFactory.sol) 合约的 create 函数来创建出新的 ComposableStablePool 合约。

  2. ComposableStablePool 的 constructor 接下来则会自动调用 vault.registerPool 和 vault.registerTokens 将本 pool 以及相关 token 注册进 vault 中。需要注意的是这里的相关 token 除了注册时指定的底层资产以外,还会包含 pool 地址本身(因为 pool 本身就是它自己的 BPT)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    // pkg/pool-stable/contracts/ComposableStablePool.sol
    constructor(NewPoolParams memory params)
    BasePool(
    params.vault,
    IVault.PoolSpecialization.GENERAL,
    params.name,
    params.symbol,
    _insertSorted(params.tokens, IERC20(this)), // <------
    new address[](params.tokens.length + 1), // <------
    params.swapFeePercentage,
    params.pauseWindowDuration,
    params.bufferPeriodDuration,
    params.owner
    )
    StablePoolAmplification(params.amplificationParameter)
    ComposableStablePoolStorage(_extractStorageParams(params))
    ComposableStablePoolRates(_extractRatesParams(params))
    ProtocolFeeCache(
    params.protocolFeeProvider,
    ProviderFeeIDs({ swap: ProtocolFeeType.SWAP, yield: ProtocolFeeType.YIELD, aum: ProtocolFeeType.AUM })
    )
    {
    _version = params.version;
    }

    // pkg/pool-utils/contracts/lib/PoolRegistrationLib.sol
    function _registerPool(
    IVault vault,
    IVault.PoolSpecialization specialization,
    IERC20[] memory tokens,
    address[] memory assetManagers
    ) private returns (bytes32) {
    bytes32 poolId = vault.registerPool(specialization);

    // We don't need to check that tokens and assetManagers have the same length, since the Vault already performs
    // that check.
    vault.registerTokens(poolId, tokens, assetManagers);

    return poolId;
    }

    这里提一嘴这个 PoolSpecialization,它决定了 Vault 在调用 pool 进行 swap 时所采用的 callback 接口形式,从而影响池子的 gas 成本与可支持的功能范围:

    • General 类型最灵活,适用于需要访问全部 token 余额和复杂数学逻辑的池。
    • Minimal Swap Info 则在保证功能性的同时减少回调数据量,常用于 Weight Pool 这类不需要全面状态的 AMM。
    • Two Token 则进一步将池子限制为仅包含两个资产,以换取最低的 swap gas 成本。

    不同类型的池根据自身 invariant 的计算需求和预期的 swap 复杂度,会选择适合的 specialization 来平衡功能与性能。每个 pool 在 constructor 时就会写死 PoolSpecialization 参数,在 ComposableStablePool 中 PoolSpecialization 就被设置为 GENERAL

  3. Vault 这边在收到 General Pool 的函数调用时会做一些计算和状态更新。一个是在 registerPool 函数中 vault 会为这个 pool 计算一个独一无二的 pool ID 并存入 _isPoolRegistered 变量中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // pkg/vault/contracts/PoolRegistry.sol
    function registerPool(PoolSpecialization specialization)
    external
    override
    nonReentrant
    whenNotPaused
    returns (bytes32)
    {
    // Each Pool is assigned a unique ID based on an incrementing nonce. This assumes there will never be more than
    // 2**80 Pools, and the nonce will not overflow.

    bytes32 poolId = _toPoolId(msg.sender, specialization, uint80(_nextPoolNonce));

    _require(!_isPoolRegistered[poolId], Errors.INVALID_POOL_ID); // Should never happen as Pool IDs are unique.
    _isPoolRegistered[poolId] = true;

    _nextPoolNonce += 1;

    // Note that msg.sender is the pool's contract
    emit PoolRegistered(poolId, msg.sender, specialization);
    return poolId;
    }

    另一个是在 registerTokens 函数中分别设置 _poolAssetManagers_generalPoolsBalances。这俩函数都是用 <poolId, token> 来作为 key 去存数据,前者表示能够操纵 Pool 内某 token 的存入/提取/设置余额的管理员地址,后者表示 Pool 内某 token 在 vault 这边的余额状态。因此可以在这里看到确实是 vault 来保存 Pool 里存放的各个 token 的数量情况,这也便于 swap 交换。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    // pkg/vault/contracts/PoolTokens.sol
    function registerTokens(
    bytes32 poolId,
    IERC20[] memory tokens,
    address[] memory assetManagers
    ) external override nonReentrant whenNotPaused onlyPool(poolId) {
    InputHelpers.ensureInputLengthMatch(tokens.length, assetManagers.length);

    // Validates token addresses and assigns Asset Managers
    for (uint256 i = 0; i < tokens.length; ++i) {
    IERC20 token = tokens[i];
    _require(token != IERC20(0), Errors.INVALID_TOKEN);

    _poolAssetManagers[poolId][token] = assetManagers[i];
    }

    PoolSpecialization specialization = _getPoolSpecialization(poolId);
    if (specialization == PoolSpecialization.TWO_TOKEN) {
    _require(tokens.length == 2, Errors.TOKENS_LENGTH_MUST_BE_2);
    _registerTwoTokenPoolTokens(poolId, tokens[0], tokens[1]);
    } else if (specialization == PoolSpecialization.MINIMAL_SWAP_INFO) {
    _registerMinimalSwapInfoPoolTokens(poolId, tokens);
    } else {
    // PoolSpecialization.GENERAL
    _registerGeneralPoolTokens(poolId, tokens); // <-------
    }

    emit TokensRegistered(poolId, tokens, assetManagers);
    }

    // pkg/vault/contracts/balances/GeneralPoolsBalance.sol
    function _registerGeneralPoolTokens(bytes32 poolId, IERC20[] memory tokens) internal {
    EnumerableMap.IERC20ToBytes32Map storage poolBalances = _generalPoolsBalances[poolId];

    for (uint256 i = 0; i < tokens.length; ++i) {
    // EnumerableMaps require an explicit initial value when creating a key-value pair: we use zero, the same
    // value that is found in uninitialized storage, which corresponds to an empty balance.
    bool added = poolBalances.set(tokens[i], 0);
    _require(added, Errors.TOKEN_ALREADY_REGISTERED);
    }
    }

    说到这里就不得不提 Pool Balances 在 vault 里的数据保存形式。_generalPoolsBalances 里为每个 pool 在某个 token 上保存的余额信息是以 bytes32 来表示,其中包含了三个字段:

    • cash 112bits,表示该 Pool 当前存放在 Vault 内的代币数量
    • managed 112bits,表示由 Pool 的 Asset Manager 从 Vault 中提走并在外部托管的代币数量
    • lastChangeBlock 32bits,表示上一次余额变动时的区块号,防止三明治攻击用的

    这个设计的核心目的是在保持 AMM 正常运作的同时,让流动性能够获得更高的收益。在早期的 Uniswap 模型中,为了维持 x·y = k,不得不把绝大部分流动性都锁在合约内部,从而无法进行任何外部投资,也就不能产生额外收益。而 Balancer 的架构允许通过 Asset Manager 将部分资金从 Vault 中划出,用于借贷、投资或执行其他收益策略。这样 Pool 的总余额依然等于 total = cash + managed,但其中的 managed 部分能够被灵活利用来赚取额外收益;只有当发生 swap、join 或 exit 等事件时,才会更新存放在 Vault 内的 cash 数量。这样既保证了 AMM 的可用性,又提高了整体资金效率。

注入流动性

当 Liquidity Provider (LP) 想为 Pool 添加流动性时,LP 可以通过调用 vault 上的 joinPool 函数来注资。vault.joinPool 函数会调用具体的 pool 的 onJoinPool 函数(退出流动性则分别调用 vault.exitPool 和 pool.onExitPool 函数)。以下是 onJoinPool / onExitPool 函数的代码,可以看出来这俩是会被所有类型的 Pool 给继承,具体不同类型的 Pool 则分别实现不同的 _onInitializePool / _onJoinPool / _onExitPool 此类 hook 函数来计算资金流入流出的数额,但 BPT 的铸造和销毁是在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
// pkg/pool-utils/contracts/BasePool.sol
/**
* @notice Vault hook for adding liquidity to a pool (including the first time, "initializing" the pool).
* @dev This function can only be called from the Vault, from `joinPool`.
*/
function onJoinPool(
bytes32 poolId,
address sender,
address recipient,
uint256[] memory balances,
uint256 lastChangeBlock,
uint256 protocolSwapFeePercentage,
bytes memory userData
) external override onlyVault(poolId) returns (uint256[] memory, uint256[] memory) {
_beforeSwapJoinExit();

uint256[] memory scalingFactors = _scalingFactors();

if (totalSupply() == 0) {
(uint256 bptAmountOut, uint256[] memory amountsIn) = _onInitializePool(
poolId,
sender,
recipient,
scalingFactors,
userData
);

// On initialization, we lock _getMinimumBpt() by minting it for the zero address. This BPT acts as a
// minimum as it will never be burned, which reduces potential issues with rounding, and also prevents the
// Pool from ever being fully drained.
_require(bptAmountOut >= _getMinimumBpt(), Errors.MINIMUM_BPT);
_mintPoolTokens(address(0), _getMinimumBpt());
_mintPoolTokens(recipient, bptAmountOut - _getMinimumBpt());

// amountsIn are amounts entering the Pool, so we round up.
_downscaleUpArray(amountsIn, scalingFactors);

return (amountsIn, new uint256[](balances.length));
} else {
_upscaleArray(balances, scalingFactors);
(uint256 bptAmountOut, uint256[] memory amountsIn) = _onJoinPool(
poolId,
sender,
recipient,
balances,
lastChangeBlock,
inRecoveryMode() ? 0 : protocolSwapFeePercentage, // Protocol fees are disabled while in recovery mode
scalingFactors,
userData
);

// Note we no longer use `balances` after calling `_onJoinPool`, which may mutate it.

_mintPoolTokens(recipient, bptAmountOut);

// amountsIn are amounts entering the Pool, so we round up.
_downscaleUpArray(amountsIn, scalingFactors);

// This Pool ignores the `dueProtocolFees` return value, so we simply return a zeroed-out array.
return (amountsIn, new uint256[](balances.length));
}
}

/**
* @notice Vault hook for removing liquidity from a pool.
* @dev This function can only be called from the Vault, from `exitPool`.
*/
function onExitPool(
bytes32 poolId,
address sender,
address recipient,
uint256[] memory balances,
uint256 lastChangeBlock,
uint256 protocolSwapFeePercentage,
bytes memory userData
) external override onlyVault(poolId) returns (uint256[] memory, uint256[] memory) {
uint256[] memory amountsOut;
uint256 bptAmountIn;

// When a user calls `exitPool`, this is the first point of entry from the Vault.
// We first check whether this is a Recovery Mode exit - if so, we proceed using this special lightweight exit
// mechanism which avoids computing any complex values, interacting with external contracts, etc., and generally
// should always work, even if the Pool's mathematics or a dependency break down.
if (userData.isRecoveryModeExitKind()) {
// This exit kind is only available in Recovery Mode.
_ensureInRecoveryMode();

// Note that we don't upscale balances nor downscale amountsOut - we don't care about scaling factors during
// a recovery mode exit.
(bptAmountIn, amountsOut) = _doRecoveryModeExit(balances, totalSupply(), userData);
} else {
// Note that we only call this if we're not in a recovery mode exit.
_beforeSwapJoinExit();

uint256[] memory scalingFactors = _scalingFactors();
_upscaleArray(balances, scalingFactors);

(bptAmountIn, amountsOut) = _onExitPool(
poolId,
sender,
recipient,
balances,
lastChangeBlock,
inRecoveryMode() ? 0 : protocolSwapFeePercentage, // Protocol fees are disabled while in recovery mode
scalingFactors,
userData
);

// amountsOut are amounts exiting the Pool, so we round down.
_downscaleDownArray(amountsOut, scalingFactors);
}

// Note we no longer use `balances` after calling `_onExitPool`, which may mutate it.

_burnPoolTokens(sender, bptAmountIn);

// This Pool ignores the `dueProtocolFees` return value, so we simply return a zeroed-out array.
return (amountsOut, new uint256[](balances.length));
}

Swap 操作

在 Balancer 中,用户可以通过 swapbatchSwap 与 Vault 进行代币交换,而无需直接信任 Pool 合约本身,因为所有安全检查均由 Vault 完成。swap 用于执行一次单独的代币兑换,batchSwap 则可在同一笔交易中按顺序执行多次兑换,并支持 multihop 形式的链式交换。

每次 swap 都包含一个 tokenIn 与一个 tokenOut:前者由用户发送给 Pool,后者由 Pool 发给接收方。根据用户的意图不同,swap 分为两类:

  • GIVEN_IN:输入数量固定,由 Pool 通过 onSwap 钩子计算输出数量
  • GIVEN_OUT:输出数量固定,也是由 Pool 通过 onSwap 钩子计算输出数量

注意:在 batchSwap 里,虽然会涉及到多个 swap 操作,但是这些 swap 操作都共用一种类型,即要么这些 swap 全是 GIVEN_IN 类型,要么全是 GIVEN_OUT 类型。这个 SwapKind 是用户在调用 batchSwap 通过函数调用参数指定的,因此不会出现一笔 batchSwap 里不同 swap 的 Kind 混着计算的情况。

无论进行多少次交换,Vault 都会先完成所有中间计算,并在最后一步一次性结算代币的净变动,从而显著节省 gas,尤其是在 multihop 或跨多个 Pool 交换时。

在 multihop 进行多次代币转换时(例如先 TokenA/TokenB swap,再 TokenB/TokenC 转换),可以在后续 swap 时设置 tokenIn amount 为 0,这将使用上一步 swap 所流出的 token 数量,以简化计算逻辑,也就是不需要用户去算每一次 swap 的数量。

注意:用户有义务根据 SwapKind 来维护正确的 Swap 顺序。例如,假设有 TokenA/TokenB 和 TokenB/TokenC 两对 swap,如果用户希望

  1. 用 100 TokenA 来 swap 出 TokenC,则需设置
    • SwapKind 为 GIVEN_IN
    • SwapSteps 为 100 TokenA/TokenB -> 0 TokenB/TokenC。表示将 100 Token A 用于兑换 TokenB,并将全部兑换到的 TokenB 用来兑换 TokenC (第二步 TokenB/TokenC 的 swap 操作 amount 被设置为 0 以表示使用上一步的兑换结果数额)。
  2. 需要用 TokenA 来 swap 出 100 TokenC,则需要设置
    • SwapKind 为 GIVEN_OUT
    • SwapSteps 为 100 TokenB/TokenC -> 0 TokenA/TokenB。表示倒推如果需要 100 个 TokenC,则需要提供多少个 TokenB,然后把所计算出所需的 TokenB 的数量再用于倒推需要提供多少个 TokenA。

由于 batchSwap 需要支持两种 swap,因此 pool 需要分别为这两种方向的 swap 实现有利于协议的份额计算方式,其函数调用路径为:batchSwap → _swapWithPools → _swapWithPool → _processGeneralPoolSwapRequest → BaseGeneralPool.onSwap → BaseGeneralPool._swapGivenIn/_swapGivenOut → 具体各个 pool 所实现的 _swapGivenIn/_swapGivenOut hook 函数。其中 BaseGeneralPool._swapGivenIn/_swapGivenOut 这俩函数是可以被 override 的,ComposableStablePool 就是把这俩 _swapGivenIn/_swapGivenOut 函数给 override 掉用来单独特判 BPT 的 swap 逻辑。

提一嘴,对于 ComposableStablePool 这种 GeneralPool 来说,vault 在处理 swap 时所操作的 pool 余额就是我们之前已经介绍过的 _generalPoolsBalances 状态变量,可以从这里快速看出它是怎么记账的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// pkg/vault/contracts/Swaps.sol
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);
}

三、漏洞分析

我们来看一下这个漏洞是怎么触发的。首先我们需要 clone balancer/balancer-v2-monorepo 的仓库,并 checkout commit 为 88842344fb5f44d8ed6f8f944acd3be80627df87。

注意 balancer 的最新版本为 v3,因此 github 里还有一个 v3 的仓库,但漏洞出现的地方是在 v2 版本,不要弄错。以及此 commit 为截至 2025/11/20 的最新 commit,在攻击事件发生两周之后漏洞补丁仍然没有被 push 上来。

1. 漏洞代码

上一节里我们详细描述了 balancer 的交互流程,对一些操作和变量已经有了比较清晰的认知,因此理解起来这个漏洞就不再困难。这个漏洞其实很简单,当用户指定 SwapKind.GIVEN_OUT 调用 vault.batchSwap 时,如果 swap 涉及到 ComposableStablePool 且 tokenIn/tokenOut 不为 BPT,则实际会调用基类 BaseGeneralPool._swapGivenOut 来计算所需 tokenIn 的数额:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// pkg/pool-stable/contracts/ComposableStablePool.sol
/**
* @dev Override this hook called by the base class `onSwap`, to check whether we are doing a regular swap,
* or a swap involving BPT, which is equivalent to a single token join or exit. Since one of the Pool's
* tokens is the preminted BPT, we need to handle swaps where BPT is involved separately.
*
* At this point, the balances are unscaled. The indices and balances are coming from the Vault, so they
* refer to the full set of registered tokens (including BPT).
*
* If this is a swap involving BPT, call `_swapWithBpt`, which computes the amountOut using the swapFeePercentage
* and charges protocol fees, in the same manner as single token join/exits. Otherwise, perform the default
* processing for a regular swap.
*/
function _swapGivenOut(
SwapRequest memory swapRequest,
uint256[] memory registeredBalances,
uint256 registeredIndexIn,
uint256 registeredIndexOut,
uint256[] memory scalingFactors
) internal virtual override returns (uint256) {
return
(swapRequest.tokenIn == IERC20(this) || swapRequest.tokenOut == IERC20(this))
? _swapWithBpt(swapRequest, registeredBalances, registeredIndexIn, registeredIndexOut, scalingFactors)
: super._swapGivenOut( // <------ [1]
swapRequest,
registeredBalances,
registeredIndexIn,
registeredIndexOut,
scalingFactors
);
}

// pkg/pool-utils/contracts/BaseGeneralPool.sol
function _swapGivenOut(
SwapRequest memory swapRequest,
uint256[] memory balances,
uint256 indexIn,
uint256 indexOut,
uint256[] memory scalingFactors
) internal virtual returns (uint256) {
_upscaleArray(balances, scalingFactors);
swapRequest.amount = _upscale(swapRequest.amount, scalingFactors[indexOut]); // <---- [2]

uint256 amountIn = _onSwapGivenOut(swapRequest, balances, indexIn, indexOut);

// amountIn tokens are entering the Pool, so we round up.
amountIn = _downscaleUp(amountIn, scalingFactors[indexIn]);

// Fees are added after scaling happens, to reduce the complexity of the rounding direction analysis.
return _addSwapFeeAmount(amountIn);
}

// pkg/solidity-utils/contracts/helpers/ScalingHelpers.sol
/**
* @dev Applies `scalingFactor` to `amount`, resulting in a larger or equal value depending on whether it needed
* scaling or not.
*/
function _upscale(uint256 amount, uint256 scalingFactor) pure returns (uint256) {
// Upscale rounding wouldn't necessarily always go in the same direction: in a swap for example the balance of
// token in should be rounded up, and that of token out rounded down. This is the only place where we round in
// the same direction for all amounts, as the impact of this rounding is expected to be minimal.
return FixedPoint.mulDown(amount, scalingFactor); // <----- [3]
}

从代码中可以看到,哪怕是为 GivenOut 计算所需 tokenIn 的 amount ,BaseGeneralPool._swapGivenOut 依然会用 mulDown 来进行 upscale。然而在 GivenOut 的上下文下,mulDown 是偏向于用户而非偏向于协议的,因为 swapRequest.amount 此时表示用户需要多少个 tokenOut,如果 _upscale 计算出来的 token 价值被设置少了,那么可能就无法确保用户支付的价值足够多。

例如对于 TokenB/TokenC swap 此时 amount =100 表示用户需要100个 tokenC,以此来计算 pool 需要用户提供多少个 TokenB,如果 amount 被减小了,那么自然计算出来的需要从用户那边转账进 vault 的 tokenA 的数量就会跟着变小。

这里就是漏洞的实际关键代码。 不过从注释里看出开发者确信这里能造成的影响微乎其微(the impact of this rounding is expected to be minimal),那是什么导致本应该微乎其微的影响竟能产生如此大的代币窃取呢?这里需要仔细分析几个关键点。首先我们来捋一捋 scalingFactor 的计算过程。

在 ComposableStablePool 中,所计算的 scalingFactor 将会等于该 token 当前 decimal 乘以 token rate,这将导致最终计算出来的结果将为在 1e18 精度下的小数,并在 _upscale 中的 FixedPoint.mulDown 里最后一步将 1e18 精度除掉。

例如,假如 _scalingFactor0 为 1e18,tokenRate0 为 1.1e18,那么 _scalingFactors 函数计算出来的结果将为 1.1e18。
接下来以极小值作为 amount 参数调用 _upscale 函数,例如执行 _upscale(9, 1.1e18) ,则最终计算出来的结果将为 9.9e18 % 1e18 = 9,可以看到这里的计算丢失了 0.9 的精度,相当于是 10% 的精度损失。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// pkg/pool-stable/contracts/ComposableStablePoolRates.sol
/**
* @dev Overrides scaling factor getter to compute the tokens' rates.
*/
function _scalingFactors() internal view virtual override returns (uint256[] memory) {
// There is no need to check the arrays length since both are based on `_getTotalTokens`
uint256 totalTokens = _getTotalTokens();
uint256[] memory scalingFactors = new uint256[](totalTokens);

for (uint256 i = 0; i < totalTokens; ++i) {
scalingFactors[i] = _getScalingFactor(i).mulDown(_getTokenRate(i)); // <---
}

return scalingFactors;
}

但只分析到这并不够,攻击事件的核心问题并不在这。首先虽然 _upscale 的 amount 参数为 9 这种极小值时确实可以看到明显的精度丢失,但 9 这个极小值的逻辑意义就是 9 wei。要是攻击者进行一次 swap 就只窃取 1 wei 走,那这点钱可不够支付单次 swap 的 gas 费,这可能也正是 _upscale 开发者确信影响微乎其微的原因。其次,_scalingFactors 计算过程中乘以 tokenRate 的逻辑也没有问题,因为 Linear Pool 里有类似的逻辑,但 Linear Pool 却不在本次攻击范围内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// pkg/pool-linear/contracts/LinearPool.sol
function _scalingFactor(IERC20 token) internal view virtual returns (uint256) {
if (token == _mainToken) {
return _scalingFactorMainToken;
} else if (token == _wrappedToken) {
// The wrapped token's scaling factor is not constant, but increases over time as the wrapped token
// increases in value.
return _scalingFactorWrappedToken.mulDown(_getWrappedTokenRate()); // <--------
} else if (token == this) {
return FixedPoint.ONE;
} else {
_revert(Errors.INVALID_TOKEN);
}
}

那问题出在哪???是时候来学习一下 Stable Pool 的数学模型了。

2. Stable Pool 数学模型

这一节介绍一下 Stable Pool 的数学模型,出于学习的目的会涉及到额外的背景知识,并非所有内容都和漏洞有关。

在 swap 过程中控制流将通过调用链 BaseGeneralPool._swapGivenOut → ComposableStablePool._onSwapGivenOut → ComposableStablePool._onRegularSwap 进入到具体的 swap 份额计算逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* @dev Perform a swap between non-BPT tokens. Scaling and fee adjustments have been performed upstream, so
* all we need to do here is calculate the price quote, depending on the direction of the swap.
*/
function _onRegularSwap(
bool isGivenIn,
uint256 amountGiven,
uint256[] memory registeredBalances,
uint256 registeredIndexIn,
uint256 registeredIndexOut
) private view returns (uint256) {
// Adjust indices and balances for BPT token
uint256[] memory balances = _dropBptItem(registeredBalances);
uint256 indexIn = _skipBptIndex(registeredIndexIn);
uint256 indexOut = _skipBptIndex(registeredIndexOut);

(uint256 currentAmp, ) = _getAmplificationParameter();
uint256 invariant = StableMath._calculateInvariant(currentAmp, balances);

if (isGivenIn) {
return StableMath._calcOutGivenIn(currentAmp, balances, indexIn, indexOut, amountGiven, invariant);
} else {
return StableMath._calcInGivenOut(currentAmp, balances, indexIn, indexOut, amountGiven, invariant);
}
}

在这里我们可以看到几个用于计算份额的函数:

  • _getAmplificationParameter:获取放大系数,这是一个超参,可被管理员通过时间来平滑修改
  • _calculateInvariant:计算不变量 D
  • _calcOutGivenIn/_calcInGivenOut:根据之前计算出来的不变量 D 以及代币兑换方向来计算出 amountGiven 个 tokenIn 下能兑换出多少个 tokenOut

对于自动做市商 AMM 而言,它们大体上都需要遵守数学公式 $f(\mathbf{B}^{\text{prev}}; \boldsymbol{\theta})=f(\mathbf{B}^{\text{after}}; \boldsymbol{\theta})=D$,以确保代币兑换价格能够随着交易自动变动。

其中 $\mathbf{B} = (B_1, B_2,…)$ 表示多个 token 的余额(注意这里的余额是乘以 Token Rate 之后的值),$\boldsymbol{\theta}=(\theta_1,\theta_2,…)$表示超参(上面的 AmplificationParameter 就属于超参),$D$即公式的不变量,代币余额变动是通过不变量来维持价格稳定。

不同代币对的价格行为和风险特征并不相同,因此不变量函数 f 也需要因市场结构而异。对于 Stable Pool 而言,由于稳定币的兑换关系通常长期维持在固定比例,例如 1:1,自然希望该池在这一价格临界点附近拥有尽可能大的流动性深度,使得即便存在较大规模的成交,也不会引起显著价格波动,从而降低滑点并提升交易体验。但与此同时,当价格明显偏离这一固定比例时,又必须保证价格具备足够的敏感性,使得继续交易的成本迅速上升,以防止某一侧资产被过度抽干,并为套利者提供恢复价格锚定的动力。大概是这种效果:

  • 价格-流动性图:可以看到价格在 1.0 附近的流动性非常多,因为 stable coin 本身价格的变动就极其轻微;而偏远价格的流动性相对较低。

    price-liquidity.png

  • TokenA流动性-TokenB流动性图 (50/50 Stable Pool):大概是途中橙线的效果,在0.5附近的样子接近常和线,在极端情况下的样子接近常积线。

    图是用 chatgpt 画出来的,因此此图就是大概让读者看个样子有个预期印象,不能深究数学公式。

    reservePrice.png

因此,Stable Pool 所采用的不变量函数并非单纯的常和模型或常积模型,而是通过引入放大系数等超参数,在二者之间实现连续过渡:

  • 在价格接近锚定区间时,其行为更接近常和曲线 x + y = k,从而提供近乎稳定的兑换比率
  • 随着价格逐渐偏离该区间,不变量函数又逐步向常积曲线 x * y = k 退化,使价格曲线变得更加陡峭,强化系统在极端情况下的安全性与稳健性

_calculateInvariant 函数里维持了这样的不变量公式,其中:

$$A n^{n} S + D = A D n^{n}+\frac{D^{n+1}}{n^{n} P},\quad S = \sum_{i=1}^{n} x_i,; P = \prod_{i=1}^{n} x_i $$

  • A:放大系数,超参。
  • n:代币总个数
  • S:全部代币总余额之
  • P:全部代币总余额之
  • D:上面提到的在代币 swap 时需要维持的不变量待计算的值

注:这里的数学公式与漏洞利用无关,不感兴趣的读者可以跳过。

这样一来,如果:

  • 池子接近平衡状态:例如所有余额满足 $x_i \approx \frac{D}{n}$ 时,有$P \approx \left(\frac{D}{n}\right)^n$,此时方程中由 A 放大的项占主导,解趋近于$D \approx S$,从而得到 $x_1 + x_2 + \cdots + x_n \approx D$。这表明在锚定价格附近,池子的行为接近常和模型,价格曲线几乎线性,滑点极小,对应高流动性与价格稳定区域。
  • 某个代币余额趋近于零:例如 $x_k \to 0$,则有 $P = \prod_{i=1}^{n} x_i \to 0$,此时项$\frac{D^{n+1}}{n^{n} P}$变得极大并主导整个等式,使其近似满足$D^{n+1} \propto P$,从而在边界区域退化为类似常积模型的行为,价格随交易量急剧变化,滑点迅速增大,有效阻止单一资产被完全抽空。

上面 _calculateInvariant函数里不变量公式的自变量为各个代币的余额,因变量为不变量 D。 这里为了计算出 D,_calculateInvariant 函数使用 Newton–Raphson 迭代公式 $D_{k+1} = D_k - \frac{f(D_k)}{f’(D_k)}$ 进行最多 256 次迭代来计算出 D 值,更具体的数学细节就不展开了,感兴趣的可以找 ChatGPT 老师做更多解释。

在计算出不变量 D 之后,_calcInGivenOut 函数基于上述不变量公式进行变换,得到以下新公式 $x^2 + \left( S_{\setminus x} + \frac{D}{A \cdot n^n} - D \right) x - \frac{D^{,n+1}}{A \cdot n^{2n} \cdot P_{\setminus x}} = 0$并尝试求解变量 x。其中:

  • $x$ :表示当 tokenIn 流入之后 tokenIn 的新余额 (即$x=B_x + Amout_{in}$,而这个$Amount_{in}$就是 _calcInGivenOut 最终要求的值)。待计算的值
  • $S_{\setminus x}$:表示除了 tokenX 以外剩余其他 token 余额的总和。
  • $P_{\setminus x}$:表示除了 tokenX 以外剩余其他 token 余额的总积。

这样,在计算出 tokenIn 的预期新余额之后,减去当前余额就能得到期望用户输入的 tokenIn 代币数 $Amout_{in}$。

那么 BPT 的价格该如何计算呢? 看得出来 BPT 的价格与不变量 D 是正相关的,符合 $P_{BPT} = \frac{D}{S_{BPT}}$,其中$P_{BPT}$ 为 BPT 的价格,$S_{BPT}$ 为 BPT 的总供应量。但是要注意,虽然D名为不变量,但它并不代表是一成不变的,详见底下的代码注释,这里不再展开。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* @dev This function returns the appreciation of BPT relative to the underlying tokens, as an 18 decimal fixed
* point number. It is simply the ratio of the invariant to the BPT supply.
*
* The total supply is initialized to equal the invariant, so this value starts at one. During Pool operation the
* invariant always grows and shrinks either proportionally to the total supply (in scenarios with no price impact,
* e.g. proportional joins), or grows faster and shrinks more slowly than it (whenever swap fees are collected or
* the token rates increase). Therefore, the rate is a monotonically increasing function *as long as the tokens
* in the pool do not lose value*.
*
* ...
*/
function getRate() external view virtual override returns (uint256) {
// We need to compute the current invariant and actual total supply. The latter includes protocol fees that have
// accrued but are not yet minted: in calculating these we'll actually end up fetching most of the data we need
// for the invariant.

(
uint256[] memory balances,
uint256 virtualSupply,
uint256 protocolFeeAmount,
uint256 lastJoinExitAmp,
uint256 currentInvariantWithLastJoinExitAmp
) = _getSupplyAndFeesData();

// Due protocol fees will be minted at the next join or exit, so we can simply add them to the current virtual
// supply to get the actual supply.
uint256 actualTotalSupply = virtualSupply.add(protocolFeeAmount);

// All that's missing now is the invariant. We have the balances required to calculate it already, but still
// need the current amplification factor.
(uint256 currentAmp, ) = _getAmplificationParameter();

// It turns out that the process for due protocol fee calculation involves computing the current invariant,
// except using the amplification factor at the last join or exit. This would typically not be terribly useful,
// but since the amplification factor only changes rarely there is high probability of its current value being
// the same as it was in the last join or exit. If that is the case, then we can skip the costly invariant
// computation altogether.
uint256 currentInvariant = (currentAmp == lastJoinExitAmp)
? currentInvariantWithLastJoinExitAmp
: StableMath._calculateInvariant(currentAmp, balances);

// With the current invariant and actual total supply, we can compute the rate as a fixed-point number.
return currentInvariant.divDown(actualTotalSupply);
}

3. 攻击流程

在第一小节里我们提到了如果传入了一个极小值给 _upscale 函数,那么其计算结果会出现较大的精度损失,但我们仍然还没搞清楚是如何通过这个几 wei 的极小值来窃取大额资金的。第二小节里我们详细了解了 Stable Pool 的不变量公式以及 BPT 价格的计算方式。

回顾一下:

  1. Stable Pool 的数学模型表现为常和与常积的结合,即在锚定价格附近表现为 x + y = k,偏离锚定价格较远的位置则表现为 x * y = k
  2. 不变量D的计算是通过各个 token 的 balance 来得到的,那么不变量 D 一定和各个 token 的 balance 呈正相关。这个很容易证明,当有 LP 注入流动性的时候 token balance 增加,那么 D 也要增加以对应新增发的 BPT,反之如果 LP 撤离流动性则 token balance 降低,D 也应该随之变小。
  3. BPT 价格是由不变量 D 和当前总供应量得到。在总供应量不变的情况下如果能通过漏洞把 D 降低那么就能以比正常价格低的价格来兑换 BPT。

那么攻击思路就开始逐渐清晰了:在每次 Swap 时,不变量 D 的计算都是由 token balance 来计算得到。如果能通过这种细微的 token balance 计算的精度丢失,使得不变量 D 的计算被压小(在 BPT 总供应量不变的前提下),那么攻击者就可以以较低的价格来购买 BPT,因为 BPT 价格受到不变量 D 的直接影响

Arbitrum 上的示例攻击交易展示了攻击的全流程,我们可以发现攻击者正是通过 _upscale 函数精度损失,使得 D 的计算偏离正常值,进而影响 BPT 价格来进行攻击。具体来说,攻击的流程是这样的。

  1. 流动性操纵。Balancer batchSwap 允许临时借用内部余额,因此攻击者首先借用了 Balancer 中该 Pool 的 BPT,进而使用这些 BPT 去换取底层的 rETH/cbETH/wstETH 等资产代币。使得这些本来余额为 1e18 数量级的代币,在经过大量兑换之后池子里只剩下 1e11 级别的数量:

    image.png

    上图中 TokenIn 为 wstETH/rETH/cbETH 的这个其实就是该池子的 BPT(因为 BPT 的地址就是该池子的地址)。从图上可以看到从上到下每次兑换底层资产的数量级依次递减,直到最后 swap 的数量低于 100wei。从余额变动来看,最开始进行 swap 时代币的数量分别为:

    • cbETH: 385e18 (385,331,897,945,415,101,145)
    • BPT: 2.45e18 + 2^11 (2,596,148,429,267,416,263,499,288,948,276,786)
    • wstETH: 36.4e18 (36,378,350,238,858,588,950)
    • rETH: 41.3e18 (41,301,528,246,890,260,702)

    注:BPT 余额中 2^11 部分为 _PREMINTED_TOKEN_BALANCE,详见代码。

    而在完成流动性操纵之后最终的代币数量分别为:

    • cbETH: 1.00e11 (100,000,000,000)
    • BPT: 501.96e18 + 2^11 (2,596,148,429,267,915,775,463,860,923,420,341)
    • wstETH: 1.00e11 (100,000,000,000)
    • rETH: 1.00e11 (100,000,000,000)
  2. 通过舍入漏洞频繁压低不变量 D。这一步骤只涉及到 wstETH/cbETH 交易。攻击者通过重复多次以下 swap 步骤来达到目的:

    1. wstETH→cbETH: 这一步将耗尽 wstETH 流动性,使得流动性从较高的 1e11 降低为 9 这个临界值。
    2. wstETH→cbETH: 这一步使用精心构建的 amount = 8, 触发 upscale 的精度损失。 此时 cbETH 的 token rate 为 1.114。因此在计算不变量 D 之前,upscale 会计算 balance(8) * rate(1.114) = value(8.912) 并截断为 8。这样一来,在计算不变量 D 时,由于使用的 token balance 为截断后的 value,因此所计算出来的 D 的值将会被恶意下压。
    3. cbETH→wstETH: 在完成上一步的步骤将 D 向下压缩之后,这一步的 swap 只是将 wstETH 的流动性从 1 恢复为例如 5642 这种较高值,以准备下一次执行 a 步骤。

    image.png

  3. 由于已经通过多次舍入攻击把不变量 D 压的很小,因此攻击者可以以较低价格来回购 BPT,用以偿还从 Vault 的 batchSwap 里借用的内部余额。而 BPT 的前后价格差就是攻击者窃取金额的关键。下图展示了攻击者花费底层代币 cbETH/wstETH/rETH 回购 BPT 的交易过程,这里攻击者每次回购 BPT 的数量呈指数级上升,用于快速回购回最开始从 Vault 借入的用于枯竭掉 Pool 流动性所花费的 BPT。

    image.png

四、参考链接

  1. https://www.coinspect.com/blog/balancer-rate-manipulation-exploit/
  2. https://blocksecteam.medium.com/in-depth-analysis-the-balancer-v2-exploit-9552f6442437
  3. https://www.openzeppelin.com/news/understanding-the-balancer-v2-exploit
  4. https://mp.weixin.qq.com/s/zywPIK08hpy-Ug6rc9Qysw
  5. https://x.com/Balancer/status/1986104426667401241
  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2020-2025 Kiprey
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~