本文深入探讨了Solidity中由于固定点运算特性可能导致的精度损失问题,尤其是在去中心化金融(DeFi)项目中函数、合约和库之间传递数据时。
Solidity 使用定点运算,因此由于舍入,乘法前的除法可能导致精度损失错误。Solidity 中的数字在组合之前也需要缩放到相同的精度。大多数 Solidity 开发者都知道这些要求,因此很少发现表面级别的精度损失漏洞,但对于有洞察力的审计员来说,发现隐藏的精度损失漏洞是非常有可能的。
隐藏的精度损失漏洞可能出现在模块化智能合约项目中,其中数字在函数、合约和库之间被操作和传递。本文将使用 Sherlock 最近USSD 审计竞赛中的一个真实示例,重点介绍精英智能合约审计员用于查找和最大化隐藏精度损失漏洞的技术。
去中心化金融 (DeFi) 通常以 Solidity 代码实现方程式为特色。新手审计员的眼睛会忽略实现数学方程式的代码行,但经验丰富的审计员会使用一种特定的技术来分析这些:手动展开方程式中的函数调用和变量,以暴露隐藏的乘法前除法。让我们考虑一个来自 USSDRebalancer.BuyUSSDSellCollateral() 的简单示例:
function BuyUSSDSellCollateral(uint256 amountToBuy) internal {
CollateralInfo[] memory collateral = IUSSD(USSD).collateralList();
uint amountToBuyLeftUSD = amountToBuy * 1e12;
这段代码看起来很无辜;amountToBuy 作为输入传递,然后相乘,会有什么问题呢?使用在方程式中展开变量的技术,我们找到了 amountToBuy 的来源:
function getSupplyProportion() public view returns (uint256, uint256) {
uint256 vol1 = IERC20Upgradeable(uniPool.token0()).balanceOf(address(uniPool));
uint256 vol2 = IERC20Upgradeable(uniPool.token1()).balanceOf(address(uniPool));
if (uniPool.token0() == USSD) {
return (vol1, vol2);
}
return (vol2, vol1);
}
function rebalance() override public {
uint256 ownval = getOwnValuation();
(uint256 USSDamount, uint256 DAIamount) = getSupplyProportion();
if (ownval < 1e6 - threshold) {
// @audit amountToBuy 是这个调用的参数
BuyUSSDSellCollateral((USSDamount - DAIamount / 1e12)/2);
然后,我们使用 amountToBuy 的定义来展开 amountToBuyLeftUSD 的定义:
amountToBuyLeftUSD = amountToBuy * 1e12;
amountToBuyLeftUSD = ((USSDamount - DAIamount / 1e12)/2) * 1e12;
现在,隐藏在函数调用和变量定义后面的可能的精度损失变得显而易见:先前除以 2 的 amountToBuy 随后再次相乘,结果存储在 amountToBuyLeftUSD 中,由于乘法前除法,导致潜在的精度损失。
我们如何确定这里会发生精度损失,一旦确定,我们如何最大化这个发现?
一旦我们有了展开的方程式,我们的下一步就是简化它,以消除乘法前的除法,并获得一个简化的“正确”形式,我们可以对其进行测试:
amountToBuyLeftUSD = ((USSDamount - DAIamount / 1e12)/2) * 1e12;
// @audit /2 * 1e12 可以重写为 * 1e12 / 2,
// 消除乘法前的除法,解决
// 精度损失
= (USSDamount - DAIamount / 1e12) * 1e12 / 2;
然后,我们想要创建一个简单的合约 src/PrecisionLoss.sol,该合约实现方程式的原始版本和简化版本:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PrecisionLoss {
function ussdOriginalAmountToBuy(uint ussdAmount, uint daiAmount)
public pure returns (uint) {
// @audit /2 * 1e12 乘法前的除法
// 会导致精度损失
return (ussdAmount - daiAmount / 1e12)/2 * 1e12;
}
function ussdSimplifiedAmountToBuy(uint ussdAmount, uint daiAmount)
public pure returns (uint) {
// @audit /2 * 1e12 可以重写为 * 1e12 / 2,
// 消除乘法前的除法,解决精度
// 损失
return (ussdAmount - daiAmount / 1e12) * 1e12 / 2;
}
}
接下来,我们想要使用 Foundry 的不变量模糊测试 来:
检测两个方程式之间是否真的存在精度损失,
如果是,则最大化/优化利用它所需的输入参数,
我们特别想寻找一组输入,其中原始方程式将等于 0,但简化方程式将大于 0,因为这通常是一种更具破坏性的精度损失形式。
首先,我们将创建一个处理程序 test/InvariantPrecisionLossHandler.sol。这将先前创建的 PrecisionLoss 合约作为输入,并实现一个模糊测试函数,该函数将:
定义我们想要测试的输入范围,
调用我们先前创建的合约中的原始函数和简化函数,
包含一些逻辑来优化我们感兴趣的参数的发现,以最大化发现:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {PrecisionLoss} from "../src/PrecisionLoss.sol";
import {console2} from "forge-std/console2.sol";
import {CommonBase} from "forge-std/Base.sol";
import {StdUtils} from "forge-std/StdUtils.sol";
contract InvariantPrecisionLossHandler is CommonBase, StdUtils {
// 正在测试的真实合约
PrecisionLoss internal _underlying;
// 不变量变量,设置为 1,因为不变量将是
// errorOutput != 0,所以不希望它立即失败
uint public originalOutput = 1;
uint public simplifiedOutput = 1;
// 优化的查找变量
uint public maxPrecisionLoss;
uint public mplUssdAmount;
uint public mplDaiAmount;
constructor(PrecisionLoss underlying) {
_underlying = underlying;
}
// 将在不变量模糊测试期间调用的函数
function ussdAmountToBuy(uint uusdAmount, uint daiAmount) public {
// 将输入约束在各自精度范围内的 $1 和 $1B 之间
uusdAmount = bound(uusdAmount, 1e6 , 1000000000e6 );
daiAmount = bound(daiAmount , 1e18, 1000000000e18);
// 被测试函数的要求
vm.assume(uusdAmount > daiAmount/1e12);
// 运行原始函数和简化函数
originalOutput = _underlying.ussdOriginalAmountToBuy(uusdAmount, daiAmount);
simplifiedOutput = _underlying.ussdSimplifiedAmountToBuy(uusdAmount, daiAmount);
// 找到精度损失的差异
uint precisionLoss = simplifiedOutput - originalOutput;
//
// 如果此运行产生的精度损失大于
// 之前的所有精度损失,或者如果精度损失相同 并且
// originalOutput == 0 并且 simplifiedOutput > 0,则保存它
// 及其输入
//
// 我们真的有兴趣看看我们是否可以达到一种状态
// 其中 originalOutput == 0 && simplifiedOutput > 0,因为这
// 是一种更具破坏性的精度损失形式
//
// 也可以针对产生精度损失所需的最低 uusdAmount 和 daiAmount 进行优化
//
if(precisionLoss > 0) {
if(precisionLoss > maxPrecisionLoss ||
(precisionLoss == maxPrecisionLoss
&& originalOutput == 0 && simplifiedOutput > 0)) {
maxPrecisionLoss = precisionLoss;
mplUssdAmount = uusdAmount;
mplDaiAmount = daiAmount;
console2.log("originalOutput : ", originalOutput);
console2.log("simplifiedOutput : ", simplifiedOutput);
console2.log("maxPrecisionLoss : ", maxPrecisionLoss);
console2.log("mplUssdAmount : ", mplUssdAmount);
console2.log("mplDaiAmount : ", mplDaiAmount);
}
}
}
}
其次,我们将创建实际的测试本身 test/InvariantPrecisionLoss.t.sol,它创建并设置处理程序并定义要测试的不变量。请注意,这使用的是 forge-std 的 v1.5.5;如果这无法编译,请更新(如果你使用的是旧版本),因为存在与不变量相关的重大更改。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {PrecisionLoss} from "../src/PrecisionLoss.sol";
import {InvariantPrecisionLossHandler} from "./InvariantPrecisionLossHandler.sol";
import {console2} from "forge-std/console2.sol";
import {Test} from "forge-std/Test.sol";
contract InvariantPrecisionLossTest is Test {
// 真实合约
PrecisionLoss internal _underlying;
// 暴露真实合约的处理程序
InvariantPrecisionLossHandler internal _handler;
function setUp() public {
_underlying = new PrecisionLoss();
_handler = new InvariantPrecisionLossHandler(_underlying);
// 不变量模糊测试以 _handler 合约为目标
targetContract(address(_handler));
// 在不变量测试期间要定位的函数
bytes4[] memory selectors = new bytes4[](1);
selectors[0] = _handler.ussdAmountToBuy.selector;
targetSelector(FuzzSelector({
addr: address(_handler),
selectors: selectors
}));
}
// 不变量:原始输出不为 0。我们想看看是否
// 存在一组输入,其中原始方程式
// originalOutput == 0 但简化方程式 > 0
// 设置此不变量使 Foundry 尝试打破它
// 这极大地提高了模糊测试的效率
function invariant_originalOutputNotZero() public view {
assert(_handler.originalOutput() != 0);
}
}
我们可以运行这个测试:forge test --match-test invariant_originalOutputNotZero -vvv(使用 forge 0.2.0 a26edce 2023-05-25T00:04:00.488745146Z 或更高版本,因为存在重大更改,其中 --match 变为 --match-test),它非常快速地找到一组输入,这些输入:
在原始方程式和简化方程式之间创建精度损失,
导致 original == 0 但 simplified > 0
以下是模糊测试运行中的两组输入,它们实现了这些目标:
originalOutput : 0
simplifiedOutput : 500000000000
maxPrecisionLoss : 500000000000
mplUssdAmount : 1000001
mplDaiAmount : 1000000000000000002
originalOutput : 0
simplifiedOutput : 500000000000
maxPrecisionLoss : 500000000000
mplUssdAmount : 1000000000000000
mplDaiAmount : 999999999999999999999999999
现在让我们考虑我们的简化方程式:
(ussdAmount - daiAmount / 1e12) * 1e12 / 2
仍然存在一个初始除法,其中 daiAmount 除以 1e12;这是必需的,因为 ussdAmount 有 6 位小数,而 daiAmount 有 18 位小数,因此在组合之前必须将它们缩放到相同的精度。但是,这可能会引入另一种精度损失来源,因为结果随后会再次相乘。
另一种选择是向上缩放 ussdAmount,而不是向下缩放 daiAmount;让我们尝试这种方法,看看我们是否可以进行进一步的改进。将这个新函数添加到 src/PrecisionLoss.sol 中:
function ussdImprovedAmountToBuy(uint ussdAmount, uint daiAmount)
public pure returns (uint) {
// @audit 1e12 / 2 可以简化为 * 5e11
// = (ussdAmount - daiAmount / 1e12) * 5e11
// 要删除 / 1e12,将所有内容乘以 1e12 / 1e12
// = (1e12*ussdAmount - daiAmount) / 1e12 * 5e11
// 最后 / 1e12 * 5e11 可以重写为 * 5e11 / 1e12
// = (1e12*ussdAmount - daiAmount) * 5e11 / 1e12
return (1e12*ussdAmount - daiAmount) * 5e11 / 1e12;
}
这个改进的方程式现在向上缩放 ussdAmount,执行减法,然后执行乘法和最终除法;我们已经完全消除了所有乘法前的除法。
为了验证我们改进的方程式是否比我们的简化方程式更好,我们将这个无状态模糊测试添加到 test/InvariantPrecisionLoss.t.sol: 中:
// 无状态模糊测试,以检查改进的版本是否比简化版本保留更多的精度,
// 并比较所有 3 个版本(原始版本、简化版本、改进版本)
function testUssdImprovedAmountToBuy(uint uusdAmount, uint daiAmount) public {
// 将输入约束在各自精度范围内的 $1 和 $1B 之间
uusdAmount = bound(uusdAmount, 1e6 , 1000000000e6 );
daiAmount = bound(daiAmount , 1e18, 1000000000e18);
// 被测试函数的要求
vm.assume(uusdAmount > daiAmount/1e12);
// 运行原始函数、简化函数和改进函数
uint originalOutput = _underlying.ussdOriginalAmountToBuy(uusdAmount, daiAmount);
uint simplifiedOutput = _underlying.ussdSimplifiedAmountToBuy(uusdAmount, daiAmount);
uint improvedOutput = _underlying.ussdImprovedAmountToBuy(uusdAmount, daiAmount);
console2.log("uusdAmount : ", uusdAmount);
console2.log("daiAmount : ", daiAmount);
console2.log("originalOutput : ", originalOutput);
console2.log("simplifiedOutput : ", simplifiedOutput);
console2.log("improvedOutput : ", improvedOutput);
// 如果改进的输出和简化的输出不匹配,则测试失败
assertEq(simplifiedOutput, improvedOutput);
}
在运行此测试之前,我们想要将以下内容添加到 foundry.toml 中,以增加模糊测试运行的次数:
[fuzz]
runs = 100000
max_local_rejects = 999999999
max_test_rejects = 999999999
然后运行测试:forge test --match-test testUssdImprovedAmountToBuy -vvv
经过几次运行后,我们可以看到改进的版本比简化版本效果更好,以下是一些运行输出:
uusdAmount : 1000001
daiAmount : 1000000000000000001
originalOutput : 0
simplifiedOutput : 500000000000
improvedOutput : 499999999999
uusdAmount : 999999999000005
daiAmount : 1000000000000000001
originalOutput : 499999999000002000000000000
simplifiedOutput : 499999999000002500000000000
improvedOutput : 499999999000002499999999999
uusdAmount : 999999999003061
daiAmount : 999999999000000000000001942
originalOutput : 1530000000000000
simplifiedOutput : 1530500000000000
improvedOutput : 1530499999999029
我们现在已经验证,完全消除所有乘法前除法的改进形式的方程式比我们最初的简化形式保留了更多的精度。
有时,尽管存在乘法前的除法,但不会发生精度损失。在这种情况下,仍然最好用更高效且更易于理解的简化版本替换原始实现。先前概述的相同方法对于开发人员将方程式重构为简化形式,同时通过自动模糊测试确保正确性非常有帮助。考虑一下来自 USSD.collateralFactor() 的这个方程式:
totalAssetsUSD +=
(((IERC20Upgradeable(collateral[i].token).balanceOf(
address(this)
) * 1e18) /
(10 **
IERC20MetadataUpgradeable(collateral[i].token)
.decimals())) *
collateral[i].oracle.getPriceUSD()) /
1e18;
立即使用此类方程式的一种技术是重命名定义以更轻松地了解发生了什么。我们将向 src/PrecisionLoss.sol 添加 2 个函数,以包含此方程式的原始版本和简化版本:
function ussdOriginalTotalAssets(
uint balance, uint decimals, uint priceFiat)
public pure returns (uint) {
return (balance * 1e18 / (10**decimals)) * priceFiat / 1e18;
}
function ussdSimplifiedTotalAssets(
uint balance, uint decimals, uint priceFiat)
public pure returns (uint) {
// (balance * 1e18 / (10**decimals)) * priceFiat / 1e18;
// 1) 乘以和除以 1e18 抵消:
// (balance / (10**decimals)) * priceFiat
// 2) 更改运算顺序以首先执行乘法
return balance * priceFiat / (10 ** decimals);
}
在此项目中,balance 可以是 18 或 8 个小数点,因此我们将向 test/InvariantPrecisionLoss.t.sol: 添加几个简单的无状态模糊测试函数:
function testUssdTotalAssets18D(uint balance, uint priceFiat) public {
uint decimals = 18;
// 将输入约束在各自精度范围内的 $1 和 $1B 之间
balance = bound(balance , 1e18, 1000000000e18);
priceFiat = bound(priceFiat, 1e18, 1000000000e18);
uint originalOutput = _underlying.ussdOriginalTotalAssets(balance, decimals, priceFiat);
uint simplifiedOutput = _underlying.ussdSimplifiedTotalAssets(balance, decimals, priceFiat);
assertEq(originalOutput, simplifiedOutput);
}
function testUssdTotalAssets8D(uint balance, uint priceFiat) public {
uint decimals = 8;
// 将输入约束在各自精度范围内的 $1 和 $1B 之间
balance = bound(balance , 1e8, 1000000000e8);
priceFiat = bound(priceFiat, 1e18, 1000000000e18);
uint originalOutput = _underlying.ussdOriginalTotalAssets(balance, decimals, priceFiat);
uint simplifiedOutput = _underlying.ussdSimplifiedTotalAssets(balance, decimals, priceFiat);
assertEq(originalOutput, simplifiedOutput);
}
然后运行测试:forge test --match-test testUssdTotalAssets
无状态模糊测试在验证我们的简化方程式产生与原始版本相同的输出方面做得很好。
- 原文链接: dacian.me/exploiting-pre...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!