背景
20260831,Balancer v1 合约遭受了黑客攻击,损失约 234k 美元。该次攻击事件的 rootcause 是协议在舍入方向上采用了"四舍五入"的方式,而不是采用"对协议有利"的舍入方式,使得攻击者可以利用舍入的精度丢失进行获利。
本篇文章通过【事后分析】的角度,分析如何通过 fuzzing 的方式来检测出该项目存在的漏洞。
Fuzz 目标函数选择
确定要测试的函数
首先根据 rootcause 分析,攻击者利用的是 joinswapPoolAmountOut 和 exitswapPoolAmountIn 两个函数进行攻击,而舍入问题出现在 calcSingleOutGivenPoolIn 和 calcSingleInGivenPoolOut 两个函数中,且这两个函数作为互为"双生函数"(成对出现、具有高度对称性、互补性或协同工作关系的两个函数)。
此时需要选择对哪组函数进行 fuzz:
-
joinswapPoolAmountOut和exitswapPoolAmountIn:针对外部函数,贴合实际操作,保留参数检查与约束,一旦 fuzz 出来问题,那就意味着这个问题是可利用的。 -
calcSingleOutGivenPoolIn和calcSingleInGivenPoolOut:只针对内部函数,简单直接。但是无法保证 fuzz 的参数是符合实际情况的(需要额外添加约束或验证)。即使 fuzz 出问题,也还是需要进一步分析传入参数的场景是否能够被构造出来。
由于本次是从事后分析的角度来编写 fuzz,所以直接采用范围更小的内部函数作为目标。
Fuzz 参数取值范围与约束
分析所需要用到的全局变量和输出参数:
收集全局变量、输入参数、输出参数
全局变量取值范围及约束关系
输入参数取值范围及约束
变量取值范围:
变量类型对应的取值范围
根据上游函数对变量值的检查
根据变量运算时对值的检查
如果协议已经部署,可以从链上获取一些固定值的参数
变量取值约束:
根据上游函数对变量值之间的约束
根据变量运算时对值的约束
calcSingleOutGivenPoolIn 和 calcSingleInGivenPoolOut 两个函数均为 pure 函数,池状态经参数传入,fuzz 输入即函数签名的全部参数;常量由测试合约继承自动生效,无需在 fuzz 中处理。
TypeScript
calcSingleOutGivenPoolIn(tokenBalanceOut, tokenWeightOut, poolSupply, totalWeight, poolAmountIn, swapFee) → tokenAmountOut
calcSingleInGivenPoolOut(tokenBalanceIn, tokenWeightIn, poolSupply, totalWeight, poolAmountOut, swapFee) → tokenAmountIn
首先分析该函数所涉及的变量
由于 calcSingleOutGivenPoolIn 和 calcSingleInGivenPoolOut 两个函数为双生函数,所输入的全局变量为同一套变量。
calcSingleOutGivenPoolIn
-
全局变量(池状态):
tokenBalanceOut、tokenWeightOut、poolSupply、totalWeight、swapFee -
输入参数:
poolAmountIn -
输出参数:
tokenAmountOut
全局变量取值范围及约束关系
-
tokenBalanceOut:-
取值范围:
[1, 2^256-1] -
取值约束:
tokenOutRatio × tokenBalanceOut ≤ 2^256-1(bmul函数)
-
-
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-1(bdiv溢出)。
-
-
totalWeight:-
取值范围:
[tokenWeightOut + 1e18, 50e18] -
取值约束:
totalWeight ≥ tokenWeightOut + 1e18
-
-
swapFee:- 取值范围:
[1e12, 1e17],setSwapFee的[MIN_FEE, MAX_FEE]
- 取值范围:
-
poolAmountIn:-
取值范围:
[0, poolSupply) -
取值约束:
tokenAmountOut ≤ tokenBalanceOut/3(exitswapPoolAmountIn约束)
-
calcSingleInGivenPoolOut
-
全局变量(池状态):
tokenBalanceIn、tokenWeightIn、poolSupply、totalWeight、swapFee -
输入参数:
poolAmountOut -
输出参数:
tokenAmountIn
全局变量取值范围及约束关系
-
tokenBalanceIn:-
取值范围:
[1, 2^256-1] -
取值约束:
tokenInRatio × tokenBalanceIn ≤ 2^256-1(bmul函数)
-
-
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-1(bdiv溢出)。
-
-
totalWeight:-
取值范围:
[tokenWeightIn + 1e18, 50e18] -
取值约束:
totalWeight ≥ tokenWeightIn + 1e18
-
-
swapFee:- 取值范围:
[1e12, 1e17],setSwapFee的[MIN_FEE, MAX_FEE]
- 取值范围:
-
poolAmountOut:-
取值范围:
[0, poolSupply) -
取值约束:
tokenAmountIn ≤ tokenBalanceIn/2(joinswapPoolAmountOut约束)
-
totalWeight 的值虽然通过静态代码分析得到的取值范围是 [tokenWeightOut + 1e18, 50e18],但是通过代码实现可以看出,在设置 finalized 以后,totalWeight 的值将成为一个固定值,不能再被修改。所以针对这种类型的取值,如果项目已经部署,则可以在链上获取到其固定值,以缩小参数取值范围。
所以根据链上的信息,可以把以下的值进行固定:
-
totalWeight:50e18 -
tokenWeight:12.5e18 -
swapFee:0.005e18
Fuzz 目标与执行序列
分析所需要确保成立的条件或者需要告警的条件
通过代码或公式对告警条件进行表示
根据告警条件设计 fuzz 函数执行序列(人工经验收缩 fuzz 范围)
目标分析
这两个函数是双生函数,且添加了非零的 swapFee 作为兑换磨损。 所以 fuzz 的目标是找出双生函数执行后,能够造成获利的异常场景。
把异常场景的告警条件通过公式进行表示:
TypeScript
(token0Before <= token0After) && (token1Before <= token1After)
Fuzz 函数构造
确定了告警目标后,需要构造可能到达该目标的函数执行序列。这一步需要结合代码的实现与告警目标,设计需要进行测试的函数序列。一个高质量的函数序列设计,能够大幅度提高 fuzz 检测的效率,缩小 fuzz 的范围。
针对这类 swap 的双生函数:
-
calcSingleInGivenPoolOut:给定你想铸造出来的 BPT 数量,算出你需要向池子里支付多少该代币。 -
calcSingleOutGivenPoolIn:给定你想销毁的 BPT 数量,算出你能够从池子里取回多少该代币。
结合攻击者的攻击手法:
solidity
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 函数序列:
-
先调用
calcSingleInGivenPoolOut函数,以poolAmountOut作为输入(0 < poolAmountOut),获取输出值tokenAmountIn。 -
在调用
calcSingleOutGivenPoolIn函数,以poolAmountIn作为输入(0 < poolAmountIn <= poolAmountOut),获取输出值tokenAmountOut。 -
计算是否符合告警条件
(tokenAmountOut >= tokenAmountIn) && (poolAmountOut >= poolAmountIn),符合则告警。
接下来根据上面的内容编写 fuzz 脚本。
Fuzz 函数实现
fuzz 脚本的整体实现:https://gist.github.com/ACaiSec/133de9bd22364ff5c99ffe04de8efdf0#file-realpoolconfigcollector-t-sol
首先从链上获取了部分变量值作为 fuzz 过程中的固定值,这类参数变动频率不大,采用链上值更贴合实际场景。
solidity
// 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 的值平均概率地映射到了不同的数量级中。
solidity
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 的操作,分别调用了 calcSingleInGivenPoolOut 和 calcSingleOutGivenPoolIn 两个函数,如果顺利执行则返回 ok。如果最终返回值的 tokenAmountOut >= tokenAmountIn 则表明存在套利场景,需要被记录。
solidity
/// 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.csv:https://gist.github.com/ACaiSec/133de9bd22364ff5c99ffe04de8efdf0#file-real-pool-config-violations-csv
首先回顾一下代码中存在的问题:协议在舍入方向上采用了"四舍五入"的方式,而不是采用"对协议有利"的舍入方式
Plain
bmul(a, b) = (a * b + BONE / 2) / BONE
bdiv(a, b) = (a * BONE + b / 2) / b
因此每一步计算都有可能产生误差,且该误差值在 [0, 0.5] 的范围内。
Plain
|计算值 − 理论值| ≤ 0.5 wei
其次,这个误差与被运算的数值大小无关。 1e18 量级的计算和 1 量级的计算,误差都是 0.5。 这也就使得在大额交易里中误差 0.5 可忽略。而在极端的小额计算中,0.5 的误差会造成不可忽视的计算问题。
结合 real-pool-config-violations.csv 中的内容进行分析,重点关注 profitBPT > 0 (在抵消手续费之上还额外获取了份额,属于获利操作)这一列的数值,可以得出以下结论。
-
tokenAmountOut和tokenAmountIn的值都在个位和十位的数量级:表明了这个问题在 WBTC 代币输入数量级小的极端场景下更容易触发。 -
tokenAmountOut和tokenAmountIn的值始终相等:表明在本场景下套利的情况只会出现在 BPT 端,而不会出现直接套利 WBTC 的情况。
这也就说明了,攻击者为什么要把 pool 中 WBTC 的数量尽可能地兑换出来,因为需要控制计算出来 tokenAmountOut 和 tokenAmountIn 的值要尽可能地小,并且只有 pool 中持有的 BPT 足够多,才能利用舍入问题进行大额套利。
后记
这次作为一个练手的项目,所以选择了白盒的形式来进行 fuzz 测试的编写。但还是把整个 fuzz 测试编写过程中的思路与选择进行了记录,读者可以按需求选择阅读。也欢迎各位与我交流经验与心得。