智能合约 Fuzz 实践案例:Balancer 精度舍入漏洞

背景

20260831-Balancer 攻击事件

20260831,Balancer v1 合约遭受了黑客攻击,损失约 234k 美元。该次攻击事件的 rootcause 是协议在舍入方向上采用了“四舍五入”的方式,而不是采用“对协议有利”的舍入方式,使得攻击者可以利用舍入的精度丢失进行获利。

本篇文章通过【事后分析】的角度,分析如何通过 fuzzing 的方式来检测出该项目存在的漏洞。

Fuzz 目标函数选择

确定要测试的函数

首先根据 rootcause 分析,攻击者利用的是 joinswapPoolAmountOutexitswapPoolAmountIn 两个函数进行攻击,而舍入问题出现在 calcSingleOutGivenPoolIncalcSingleInGivenPoolOut 两个函数中,且这两个函数作为互为“双生函数”(成对出现、具有高度对称性、互补性或协同工作关系的两个函数)。

此时需要选择对哪组函数进行 fuzz:

  1. joinswapPoolAmountOutexitswapPoolAmountIn:针对外部函数,贴合实际操作,保留参数检查与约束,一旦 fuzz 出来问题,那就意味着这个问题是可利用的。

  2. calcSingleOutGivenPoolIncalcSingleInGivenPoolOut:只针对内部函数,简单直接。但是无法保证 fuzz 的参数是符合实际情况的(需要额外添加约束或验证)。即使 fuzz 出问题,也还是需要进一步分析传入参数的场景是否能够被构造出来。

由于本次是从事后分析的角度来编写 fuzz,所以直接采用范围更小的内部函数作为目标。

Fuzz 参数取值范围与约束

分析所需要用到的全局变量和输出参数:

  1. 收集全局变量、输入参数、输出参数

  2. 全局变量取值范围及约束关系

  3. 输入参数取值范围及约束

变量取值范围:

  1. 变量类型对应的取值范围

  2. 根据上游函数对变量值的检查

  3. 根据变量运算时对值的检查

  4. 如果协议已经部署,可以从链上获取一些固定值的参数

变量取值约束:

  1. 根据上游函数对变量值之间的约束

  2. 根据变量运算时对值的约束

calcSingleOutGivenPoolIncalcSingleInGivenPoolOut 两个函数均为 pure 函数,池状态经参数传入,fuzz 输入即函数签名的全部参数;常量由测试合约继承自动生效,无需在 fuzz 中处理。

calcSingleOutGivenPoolIn(tokenBalanceOut, tokenWeightOut, poolSupply, totalWeight, poolAmountIn, swapFee) → tokenAmountOut
calcSingleInGivenPoolOut(tokenBalanceIn, tokenWeightIn, poolSupply, totalWeight, poolAmountOut, swapFee) → tokenAmountIn

首先分析该函数所涉及的变量

由于 calcSingleOutGivenPoolIncalcSingleInGivenPoolOut 两个函数为双生函数,所输入的全局变量为同一套变量。

calcSingleOutGivenPoolIn

  • 全局变量(池状态):tokenBalanceOuttokenWeightOutpoolSupplytotalWeightswapFee

  • 输入参数:poolAmountIn

  • 输出参数:tokenAmountOut

全局变量取值范围及约束关系

  • tokenBalanceOut

    • 取值范围:[1, 2^256-1]

    • 取值约束:tokenOutRatio × tokenBalanceOut ≤ 2^256-1bmul 函数)

  • tokenWeightOut

    • 取值范围:[1e18, 49e18]。下界 = MIN_WEIGHT(1e18);上界 = 49e18 而非 MAX_WEIGHT(50e18):因 MAX_TOTAL_WEIGHT(50e18) + MIN_BOUND_TOKENS(2) 要求给其它代币至少留 1e18。
  • poolSupply

    • 取值范围:[1, 2^256-1]

    • 取值约束:(poolSupply - poolAmountIn) × 1e18 ≤ 2^256-1bdiv 溢出)。

  • totalWeight

    • 取值范围:[tokenWeightOut + 1e18, 50e18]

    • 取值约束:totalWeight ≥ tokenWeightOut + 1e18

  • swapFee

    • 取值范围:[1e12, 1e17]setSwapFee[MIN_FEE, MAX_FEE]
  • poolAmountIn

    • 取值范围:[0, poolSupply)

    • 取值约束:tokenAmountOut ≤ tokenBalanceOut/3exitswapPoolAmountIn 约束)

calcSingleInGivenPoolOut

  • 全局变量(池状态):tokenBalanceIntokenWeightInpoolSupplytotalWeightswapFee

  • 输入参数:poolAmountOut

  • 输出参数:tokenAmountIn

全局变量取值范围及约束关系

  • tokenBalanceIn

    • 取值范围:[1, 2^256-1]

    • 取值约束:tokenInRatio × tokenBalanceIn ≤ 2^256-1bmul 函数)

  • tokenWeightIn

    • 取值范围:[1e18, 49e18]。下界 = MIN_WEIGHT(1e18);上界 = 49e18 而非 MAX_WEIGHT(50e18):因 MAX_TOTAL_WEIGHT(50e18) + MIN_BOUND_TOKENS(2) 要求给其它代币至少留 1e18。
  • poolSupply

    • 取值范围:[1, 2^256-1]

    • 取值约束:(poolSupply + poolAmountOut) × 1e18 ≤ 2^256-1bdiv 溢出)。

  • totalWeight

    • 取值范围:[tokenWeightIn + 1e18, 50e18]

    • 取值约束:totalWeight ≥ tokenWeightIn + 1e18

  • swapFee

    • 取值范围:[1e12, 1e17]setSwapFee[MIN_FEE, MAX_FEE]
  • poolAmountOut

    • 取值范围:[0, poolSupply)

    • 取值约束:tokenAmountIn ≤ tokenBalanceIn/2joinswapPoolAmountOut 约束)

totalWeight 的值虽然通过静态代码分析得到的取值范围是 [tokenWeightOut + 1e18, 50e18],但是通过代码实现可以看出,在设置 finalized 以后,totalWeight 的值将成为一个固定值,不能再被修改。所以针对这种类型的取值,如果项目已经部署,则可以在链上获取到其固定值,以缩小参数取值范围。

所以根据链上的信息,可以把以下的值进行固定:

  • totalWeight :50e18

  • tokenWeight:12.5e18

  • swapFee:0.005e18

Fuzz 目标与执行序列

  1. 分析所需要确保成立的条件或者需要告警的条件

  2. 通过代码或公式对告警条件进行表示

  3. 根据告警条件设计 fuzz 函数执行序列(人工经验收缩 fuzz 范围)

目标分析

这两个函数是双生函数,且添加了非零的 swapFee 作为兑换磨损。 所以 fuzz 的目标是找出双生函数执行后,能够造成获利的异常场景。

把异常场景的告警条件通过公式进行表示:

(token0Before <= token0After) && (token1Before <= token1After)

Fuzz 函数构造

确定了告警目标后,需要构造可能到达该目标的函数执行序列。这一步需要结合代码的实现与告警目标,设计需要进行测试的函数序列。一个高质量的函数序列设计,能够大幅度提高 fuzz 检测的效率,缩小 fuzz 的范围。

针对这类 swap 的双生函数:

  • calcSingleInGivenPoolOut:给定你想铸造出来的 BPT 数量,算出你需要向池子里支付多少该代币。

  • calcSingleOutGivenPoolIn:给定你想销毁的 BPT 数量,算出你能够从池子里取回多少该代币。

结合攻击者的攻击手法:

BPT.calcSingleInGivenPoolOut(tokenBalanceIn=7, tokenWeightIn=12,500,000,000,000,000,000, poolSupply=6,076,387,197,419,862,702, totalWeight=50,000,000,000,000,000,000, poolAmountOut=302,217,104,844,356,406, swapFee=5,000,000,000,000,000) => (tokenAmountIn=1)

BPT.calcSingleOutGivenPoolIn(tokenBalanceOut=8, tokenWeightOut=12,500,000,000,000,000,000, poolSupply=6,378,604,302,264,219,108, totalWeight=50,000,000,000,000,000,000, poolAmountIn=102,090,610,291,608,743, swapFee=5,000,000,000,000,000) => (tokenAmountOut=1)

可以设计如下的 fuzz 函数序列:

  1. 先调用 calcSingleInGivenPoolOut 函数,以 poolAmountOut 作为输入(0 < poolAmountOut),获取输出值 tokenAmountIn

  2. 在调用 calcSingleOutGivenPoolIn 函数,以 poolAmountIn 作为输入(0 < poolAmountIn <= poolAmountOut),获取输出值 tokenAmountOut

  3. 计算是否符合告警条件 (tokenAmountOut >= tokenAmountIn) && (poolAmountOut >= poolAmountIn),符合则告警。

接下来根据上面的内容编写 fuzz 脚本。

Fuzz 函数实现

fuzz 脚本的整体实现:https://gist.github.com/ACaiSec/133de9bd22364ff5c99ffe04de8efdf0#file-realpoolconfigcollector-t-sol

首先从链上获取了部分变量值作为 fuzz 过程中的固定值,这类参数变动频率不大,采用链上值更贴合实际场景。

// Actual values for this case:
//   tokenWeight = 12.5e18, totalWeight = 50e18, swapFee = 5e15
// ===============================================================
uint256 internal constant POOL_TOKEN_WEIGHT = 12_500_000_000_000_000_000;
uint256 internal constant POOL_TOTAL_WEIGHT = 50_000_000_000_000_000_000;
uint256 internal constant POOL_SWAP_FEE = 5_000_000_000_000_000;

其次,在 fuzz 参数的取值区域概率上进行了优化,如果直接用 bound(seed, 1, 2^256−1) 将 seed 的值映射到 [1, 2^256−1] 区间上做均匀采样,会导致不同数量级的参数出现的概率不一致。比如个位数量级的参数出现的概率是 9/2^256−1,而百万位数量级的参数出现的概率是 8999999/2^256−1,更高数量级出现的概率更大,不利于寻找边界值。所以实现了 _logUniform 函数,把 seed 的值平均概率地映射到了不同的数量级中。

function _logUniform(uint256 seed, uint256 lo, uint256 hi) internal pure returns (uint256) {
    uint256 bits = _bitLength(lo) + (seed % (_bitLength(hi) - _bitLength(lo) + 1));

    uint256 minOfBitLength = uint256(1) << (bits - 1);
    uint256 value = minOfBitLength + ((seed >> 8) % minOfBitLength);

    if (value < lo) value = lo;
    if (value > hi) value = hi;
    return value;
}

核心操作在 _roundTrip 函数中,实现了 join 和 exit 的操作,分别调用了 calcSingleInGivenPoolOutcalcSingleOutGivenPoolIn 两个函数,如果顺利执行则返回 ok。如果最终返回值的 tokenAmountOut >= tokenAmountIn 则表明存在套利场景,需要被记录。

    /// Returns ok = false when the inputs fall outside the valid domain
    /// (bpow base out of range / overflow / tokenAmountIn == 0);
    /// in that case the caller should simply skip.
    function _roundTrip(
        uint256 tokenBalance,
        uint256 tokenWeight,
        uint256 poolSupply,
        uint256 poolAmountOut,
        uint256 poolAmountIn,
        uint256 swapFee
    ) internal view returns (bool ok, uint256 tokenAmountIn, uint256 tokenAmountOut) {
        // ---- Step 1: join ----
        try this.calcSingleInGivenPoolOut(
            tokenBalance, tokenWeight, poolSupply, POOL_TOTAL_WEIGHT, poolAmountOut, swapFee
        ) returns (uint256 value) {
            tokenAmountIn = value;
        } catch {
            return (false, 0, 0); // out of domain: always skip, never count as a test failure
        }
        // The upstream joinswapPoolAmountOut has require(tokenAmountIn != 0),
        // so a zero amount is unreachable on-chain
        if (tokenAmountIn == 0) return (false, 0, 0);

        // ---- Step 2: exit; must read the state AFTER the join ----
        try this.calcSingleOutGivenPoolIn(
            tokenBalance + tokenAmountIn,
            tokenWeight,
            poolSupply + poolAmountOut,
            POOL_TOTAL_WEIGHT,
            poolAmountIn,
            swapFee
        ) returns (uint256 value) {
            tokenAmountOut = value;
        } catch {
            return (false, 0, 0);
        }
        return (true, tokenAmountIn, tokenAmountOut);
    }

Fuzz 结果分析

在执行完 fuzz 函数后,会在 fuzz-out/real-pool-config-violations.csv 生成一个结果汇总文件。

real-pool-config-violations.csvhttps://gist.github.com/ACaiSec/133de9bd22364ff5c99ffe04de8efdf0#file-real-pool-config-violations-csv

首先回顾一下代码中存在的问题:协议在舍入方向上采用了“四舍五入”的方式,而不是采用“对协议有利”的舍入方式

bmul(a, b) = (a * b + BONE / 2) / BONE
bdiv(a, b) = (a * BONE + b / 2) / b

因此每一步计算都有可能产生误差,且该误差值在 [0, 0.5] 的范围内。

|计算值 − 理论值| ≤ 0.5 wei

其次,这个误差与被运算的数值大小无关。 1e18 量级的计算和 1 量级的计算,误差都是 0.5。 这也就使得在大额交易里中误差 0.5 可忽略。而在极端的小额计算中,0.5 的误差会造成不可忽视的计算问题。

结合 real-pool-config-violations.csv 中的内容进行分析,重点关注 profitBPT > 0 (在抵消手续费之上还额外获取了份额,属于获利操作)这一列的数值,可以得出以下结论。

  1. tokenAmountOuttokenAmountIn 的值都在个位和十位的数量级:表明了这个问题在 WBTC 代币输入数量级小的极端场景下更容易触发。

  2. tokenAmountOuttokenAmountIn 的值始终相等:表明在本场景下套利的情况只会出现在 BPT 端,而不会出现直接套利 WBTC 的情况。

这也就说明了,攻击者为什么要把 pool 中 WBTC 的数量尽可能地兑换出来,因为需要控制计算出来 tokenAmountOuttokenAmountIn 的值要尽可能地小,并且只有 pool 中持有的 BPT 足够多,才能利用舍入问题进行大额套利。

后记

这次作为一个练手的项目,所以选择了白盒的形式来进行 fuzz 测试的编写。但还是把整个 fuzz 测试编写过程中的思路与选择进行了记录,读者可以按需求选择阅读。也欢迎各位与我交流经验与心得。

posted @ 2026-09-11 17:39  ACai_sec  阅读(47)  评论(0)    收藏  举报