引言
随着 DeFi 生态系统的迅速发展,Compound Finance V2 作为该领域的先驱者之一,凭借其创新的借贷模式吸引了大量用户。然而,任何复杂的分布式应用都面临着潜在的安全威胁,尤其是涉及到价值数百万甚至上亿美金的资金流动时。因此,对 Compound Finance V2 及其分叉项目进行全面且细致的安全审计显得尤为重要。本手册旨在为开发者、安全研究员以及 DeFi 爱好者提供一份详尽的安全审计指南,帮助大家更有效地识别和防范潜在的风险。
1. 项目背景概述
Compound Finance V2 是一个基于以太坊区块链构建的开放式借贷平台,允许用户存入各种 ERC-20 底层代币并从中赚取利息,同时也允许以支付利息的形式借用市场中的代币。通过引入“利率市场”的概念,它实现了去中心化的资金池管理和自动化的利率调整机制。
2. 项目架构分析
Compound Finance V2 的核心架构组件包括:
Comptroller:控制整个系统逻辑,如利率计算、账户状态维护等。
cToken:实现 ERC-20 标准的自定义代币,代表用户在系统中的权益。
InterestRateModel:计算存款和借款利率的模型。
PriceOracle: 提供资产价格的预言机。
Governance:负责社区治理相关的功能。
2.1 Comptroller
Comptroller 合约是 Compound Finance V2 的中枢神经系统,它负责协调各个 cToken 实例的行为。主要职责有:
管理市场列表,确定哪些市场是活跃的。
function enterMarkets(address[] memory cTokens) override public returns (uint[] memory) {}
function exitMarket(address cTokenAddress) override external returns (uint) {}
...
执行跨市场操作的各类检查,如用户的头寸健康度检查等。
function mintAllowed(address cToken, address minter, uint mintAmount) override external returns (uint) {}
function redeemAllowed(address cToken, address redeemer, uint redeemTokens) override external returns (uint) {}
function borrowAllowed(address cToken, address borrower, uint borrowAmount) override external returns (uint) {}
function repayBorrowAllowed(address cToken, address payer, address borrower, uint repayAmount) override external returns (uint) {}
function liquidateBorrowAllowed(address cTokenBorrowed, address cTokenCollateral, address liquidator, address borrower, uint repayAmount) override external returns (uint) {}
...
设置和更新全局参数,如借款限额、抵押因子、清算阈值等。
function _setCloseFactor(uint newCloseFactorMantissa) external returns (uint) {}
function _setCollateralFactor(CToken cToken, uint newCollateralFactorMantissa) external returns (uint) {}
function _setLiquidationIncentive(uint newLiquidationIncentiveMantissa) external returns (uint) {}
function _setMarketBorrowCaps(CToken[] calldata cTokens, uint[] calldata newBorrowCaps) external {}
...
2.2 cToken
每个支持的 ERC-20 代币都有一个对应的 cToken 实例(即 CErc20 / CEther 合约),用于处理该代币所有与项目的交互操作。每个 cToken 除了实现了基本的代币转账功能外,还添加了一些特定于 Compound 的功能,如借贷、累积利息和分配奖励。所以我们可以将 cToken 看作是用户在 Compound 上存入资产的凭证和用户进行借贷操作的入口。
当用户将底层的资产代币存入合约后,即可铸造对应的 cToken 代币,cToken 与标的资产的兑换比例按照如下公式计算:
exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply
注意:borrows 表示借款额,cash 表示资金池余额,reserves 表示储备金。借款利率由使用率决定,存款利率由借款利率决定。
用户一般通过与不同的 cToken 合约交互来在不同的市场中进行代币的借贷操作:
function mint(uint mintAmount) override external returns (uint) {}
function redeem(uint redeemTokens) override external returns (uint) {}
function redeemUnderlying(uint redeemAmount) override external returns (uint) {}
function borrow(uint borrowAmount) override external returns (uint) {}
function repayBorrow(uint repayAmount) override external returns (uint) {}
function repayBorrowBehalf(address borrower, uint repayAmount) override external returns (uint) {}
function liquidateBorrow(address borrower, uint repayAmount, CTokenInterface cTokenCollateral) override external returns (uint) {}
...
2.3 InterestRateModel
InterestRateModel 合约定义了计算利率的方法。不同的市场可能会使用不同类型的利率模型,以适应各自的风险偏好和流动性需求。
Compound V2 的市场中使用的利率模型主要有两种,一种是直线型,一种是拐点型。
直线型模型的借款利率计算公式如下:
borrowRate = utilizationRate * (multiplierPerBlock/1e18) + baseRatePerBlock
// 借款利率 = 资金使用率 * 斜率 + 基准年利率
资金使用率的计算公式如下:
utilizationRate = borrows / (cash + borrows - reserves)
// 资金使用率 = 总借款 / (资金池余额 + 总借款 - 储备金)
存款利率则随着借款利率线性变化:
// 存款利率 = 资金使用率 * 借款利率 *(1 - 储备金率)
supplyRate = utilizationRate * borrowRate * (1 - reserveFactor)
使用率逐渐升高则意味着资金池里的钱在逐渐减少,当达到一定峰值时可能会导致用户无法正常存款和借款。为尽量避免这种情况,Compound 推出了第二种利率模型 —— 拐点型。
拐点型的借款利率计算公式如下:
// 未达到峰值拐点时则与直线型相同:
borrowRate = utilizationRate * (multiplierPerBlock/1e18) + baseRatePerBlock
// 达到峰值拐点过后的公式如下,其中 jumpMultiplierPerYear 表示剧增的斜率,kink 表示利用率的峰值拐点
borrowRate = jumpMultiplierPerYear * (utilizationRate - kink) + (kink * (multiplierPerBlock/1e18) + baseRatePerBlock)
当使用率达到一定的峰值时,会瞬间大幅提高借款利率和存款利率,激励用户多存款少借款,以此将使用率控制在合适的范围,这个峰值也被称为拐点(一般是利用率达到 80% 时)。
2.4 PriceOracle
PriceOracle 合约负责获取外部市场价格信息,并将其转换为系统内部使用的数值,这对于准确计算用户的头寸价值至关重要。
function getUnderlyingPrice(CToken cToken) public override view returns (uint) {
...
}
2.5 治理机制与激励模型
Compound 引入了一种独特的治理机制,允许持有治理代币(COMP) 的用户参与重要决策的投票,如更改某些参数或添加新的资产类型。通过发行治理代币(COMP),Compound 激励用户积极参与平台活动,并为贡献者提供奖励。详细内容可参考 Compound 官方文档和代码仓库。(https://docs.compound.finance/v2/; https://github.com/compound-finance/compound-protocol)
3. 交互流程
接下来,我们通过简单示例来说明用户在 Compound Finance V2 上进行交互的大致过程:
3.1 存款和赎回流程
如果用户 Alice 需要将 1 个 WBTC 存入 Compound,那么他将调用 cWBTC 合约的 mint 函数来进行存款。该合约继承了 cToken 合约,会先通过 mintInternal 函数内部调用 accrueInterest 函数来更新借款和存款利率,之后调用 mintFresh 进行具体的铸造操作。
mintFresh 函数会外部调用 Comptroller 合约的 mintAllowed 函数来检查当前市场是否允许存款,然后将用户的 1 个 WBTC 通过 doTransferIn 函数转入合约,再根据当时最新的兑换率为用户铸造相应数量的 cToken 代币(假设当前最新的兑换率是 0.1,那么 Alice 将收到 10 个 cWBTC 代币)。
如果 Alice 未来决定赎回存款,她可以通过调用 redeem 函数将 cWBTC 兑换回 WBTC,兑换率可能已经改变(假设为 0.15),这意味着 Alice 能够赎回 1.5 个 WBTC,其中 0.5 个 WBTC 为利息收入。
3.2 借款和还款流程
Alice 首先需要调用 Comptroller 合约的 enterMarkets 函数将她的 cWBTC 设置为可作为抵押品的状态,之后才可以进行借款。
function enterMarkets(address[] memory cTokens) override public returns (uint[] memory) {
uint len = cTokens.length;
uint[] memory results = new uint[](len);
for (uint i = 0; i < len; i++) {
CToken cToken = CToken(cTokens[i]);
results[i] = uint(addToMarketInternal(cToken, msg.sender));
}
return results;
}
function addToMarketInternal(CToken cToken, address borrower) internal returns (Error) {
Market storage marketToJoin = markets[address(cToken)];
...
marketToJoin.accountMembership[borrower] = true;
accountAssets[borrower].push(cToken);
...
}
假设 Alice 选择借出 70 个 USDC,由于 WBTC 的抵押因子为 0.75,Alice 最多可以借出相当于 75% 的 WBTC 价值资产,所以这不会超过她的最大借款额度。
注意:为了避免被清算的风险,Alice 应该保留一定的缓冲空间而不是完全用尽她的借款额度。
Alice 调用 cUSDC 合约的 borrow 函数,其会先通过 borrowInternal 函数内部调用 accrueInterest 函数来更新借款和存款利率,之后调用 borrowFresh 进行具体的借款操作。
在通过 Comptroller 合约的 borrowAllowed 函数进行用户的头寸价值检查后,先进行借款数据的记账,之后通过 doTransferOut 函数将代币转出给用户。
若 Alice 需要还款,可以通过调用 cUSDC 合约的 repayBorrow 函数自行还款,或者让其他人调用 repayBorrowBehalf 函数来代还款。
3.3 清算流程
如果 WBTC 的价格大幅下跌,使得 Alice 的抵押品价值低于其借款额度的 75%,则 Alice 的贷款头寸将处于被清算状态。
外部清算人(例如 Bob)可以调用 cUSDC 合约中的清算函数 liquidateBorrow 来帮助 Alice 清偿部分债务。其会先通过 liquidateBorrowInternal 函数同时更新 cUSDC 与还款用的抵押品 cToken 的利率,之后调用 liquidateBorrowFresh 进行具体的清算操作。
在通过 Comptroller 合约的 liquidateBorrowAllowed 函数进行是否允许清算的检查后,会先调用 repayBorrowFresh 函数将 USDC 转入合约进行还款,并更新被清算人的借款数据。接着调用 Comptroller 合约的 liquidateCalculateSeizeTokens 函数根据清算的价值来计算 Bob 可以拿到 Alice 相应价值的抵押品数量,最后通过指定抵押品市场的 cToken 合约(例如 cWBTC)的 seize 函数来为 Bob 和 Alice 转移 cToken。
打开此链接可查看上图的高清版,点击阅读原文也可直接跳转:https://www.figma.com/board/POkJlvKlWWc7jSccYMddet/Compound-V2?node-id=0-1&node-type=canvas。
Bob 为 Alice 清偿部分贷款(例如 20 USDC),并因此获得 Alice 相应价值的抵押品(如 WBTC),同时 Bob 还能额外获得一笔清算激励(假设为 5%)。最终结果是 Bob 收到了价值 21 个 USDC 的 WBTC(20 USDC 的贷款 + 1 USDC 的清算激励)。
4. 安全漏洞 Checklist
4.1 空市场导致的舍入漏洞
如果 cToken 是一个空市场的情况(即没有用户在市场中进行借贷),由于 exchangeRateStoredInternal 函数中 exchangeRate 的值依赖于合约对应的底层资产代币的数量,所以可以通过向 cToken 合约转入大量的底层资产代币来操纵 cToken 的价格。
function exchangeRateStoredInternal() virtual internal view returns (uint) {
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
/*
* If there are no tokens minted:
* exchangeRate = initialExchangeRate
*/
return initialExchangeRateMantissa;
} else {
/*
* Otherwise:
* exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply
*/
uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;
return exchangeRate;
}
}
...
function getCashPrior() virtual override internal view returns (uint) {
EIP20Interface token = EIP20Interface(underlying);
return token.balanceOf(address(this));
}
因此,可以用少量的 cToken 借出大量的其他代币,之后再调用 cToken 的 redeemUnderlying 函数来提取底层资产代币。在计算赎回时,需要扣除的 cToken 数量会由于除法的向下舍入导致结果远少于预期(几乎只有一半)。
// CToken.sol
function redeemFresh(address payable redeemer, uint redeemTokensIn, uint redeemAmountIn) internal {
...
Exp memory exchangeRate = Exp({mantissa: exchangeRateStoredInternal() });
...
if (redeemTokensIn > 0) {
...
} else {
/*
* We get the current exchange rate and calculate the amount to be redeemed:
* redeemTokens = redeemAmountIn / exchangeRate
* redeemAmount = redeemAmountIn
*/
redeemTokens = div_(redeemAmountIn, exchangeRate);
redeemAmount = redeemAmountIn;
}
}
...
// ExponentialNoError.sol
function mul_(uint a, uint b) pure internal returns (uint) {
return a * b;
}
...
function div_(uint a, Exp memory b) pure internal returns (uint) {
return div_(mul_(a, expScale), b.mantissa); // expScale = 1e18
}
...
function div_(uint a, uint b) pure internal returns (uint) {
return a / b;
}
假设此时持有的 cToken 的数量是 2(同时也是总的 totalSupply),而 exchangeRate 在经过操控后被拉高为 25,015,031,908,500,000,000,000,000,000,需要赎回的底层资产代币数量为 50,030,063,815。那么预期应该扣除的 cToken 数量应该为:
而实际计算出来的 cToken 数量却为:
因此,最后只需要清算极少数的 cToken 就可以获得从其他市场中借出的大量资产代币。
可以参考由于该漏洞导致的 Compound 分叉项目 Hundred Finance 被黑的交易:https://optimistic.etherscan.io/tx/0x6e9ebcdebbabda04fa9f2e3bc21ea8b2e4fb4bf4f4670cb8483e2f0b2604f451
审计要点:在审计时,需要关注兑换率的计算方式是否容易被操控以及舍入的方式是否恰当,同时可以建议项目团队在新的市场创建后立刻铸造小额的 cToken,以防止市场为空进而被操控。
4.2 ERC677 / ERC777 代币导致的重入漏洞
ERC677 / ERC777 是 ERC20 合约的一个扩展,兼容 ERC20 代币的协议标准。这些代币允许在转账过程中,如果接收地址是合约则会触发接收地址的回调函数(如 transferAndCall 或 tokensReceived)。
function transfer(address _to, uint256 _value) public returns (bool) {
require(superTransfer(_to, _value));
callAfterTransfer(msg.sender, _to, _value);
return true;
}
function transferFrom(address _from, address _to, uint256 _value) public returns (bool) {
require(super.transferFrom(_from, _to, _value));
callAfterTransfer(_from, _to, _value);
return true;
}
function callAfterTransfer(address _from, address _to, uint256 _value) internal {
if (AddressUtils.isContract(_to) && !contractFallback(_from, _to, _value, new bytes(0))) {
require(!isBridge(_to));
emit ContractFallbackCallFailed(_from, _to, _value);
}
}
function isBridge(address _address) public view returns (bool) {
return _address == bridgeContractAddr;
}
function contractFallback(address _from, address _to, uint256 _value, bytes _data) private returns (bool) {
return _to.call(abi.encodeWithSelector(ON_TOKEN_TRANSFER, _from, _value, _data));
}
在旧版本的 Compound Finance V2 代码中,当用户在 cToken 市场中进行借款时,会先将被借的代币转出,之后再进行借款数据的记账。
function borrowFresh(address payable borrower, uint borrowAmount) internal returns (uint) {
...
/*
* We invoke doTransferOut for the borrower and the borrowAmount.
* Note: The cToken must handle variations between ERC-20 and ETH underlying.
* On success, the cToken borrowAmount less of cash.
* doTransferOut reverts if anything goes wrong, since we can't be sure if side effects occurred.
*/
doTransferOut(borrower, borrowAmount);
/* We write the previously calculated values into storage */
accountBorrows[borrower].principal = vars.accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex;
totalBorrows = vars.totalBorrowsNew;
...
}
假如用户借出的代币是带有回调功能的 ERC677 / ERC777 代币的话,那么可以构造接收代币的恶意合约来通过回调函数重入 borrow 函数中进行再次借款,由于上一次借款时用户的借款数据还未被记账,所以此时可以成功通过账户的健康系数检查来再次借出代币。
可以参考由于该漏洞导致的 Compound 分叉项目 Hundred Finance 被黑的交易: https://blockscout.com/xdai/mainnet/tx/0x534b84f657883ddc1b66a314e8b392feb35024afdec61dfe8e7c510cfac1a098
审计要点:最新版本的 Compound V2 代码中已经修复了借款逻辑,改为先记录借款的数据再转出被借的代币。在审计中,需要关注借贷功能的相关代码是否符合 CFI(Checks-Effects-Interactions) 规范,并且需要考虑具有回调功能的代币造成的影响。
4.3 不恰当的预言机机制导致的价格操控风险
由于 Compound Finance 采用超额抵押贷款的模式,用户能借出的代币数量取决于抵押品的价值是否足够。
function borrowAllowed(address cToken, address borrower, uint borrowAmount) override external returns (uint) {
...
(Error err, , uint shortfall) = getHypotheticalAccountLiquidityInternal(borrower, CToken(cToken), 0, borrowAmount);
if (err != Error.NO_ERROR) {
return uint(err);
}
if (shortfall > 0) {
return uint(Error.INSUFFICIENT_LIQUIDITY);
}
...
}
...
function getHypotheticalAccountLiquidityInternal(
address account,
CToken cTokenModify,
uint redeemTokens,
uint borrowAmount) internal view returns (Error, uint, uint) {
...
for (uint i = 0; i < assets.length; i++) {
CToken asset = assets[i];
...
vars.oraclePriceMantissa = oracle.getUnderlyingPrice(asset);
if (vars.oraclePriceMantissa == 0) {
return (Error.PRICE_ERROR, 0, 0);
}
vars.oraclePrice = Exp({mantissa: vars.oraclePriceMantissa});
// Pre-compute a conversion factor from tokens -> ether (normalized price value)
vars.tokensToDenom = mul_(mul_(vars.collateralFactor, vars.exchangeRate), vars.oraclePrice);
...
// Calculate effects of interacting with cTokenModify
if (asset == cTokenModify) {
// redeem effect
// sumBorrowPlusEffects += tokensToDenom * redeemTokens, sumBorrowPlusEffects += oraclePrice * borrowBalance
vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.tokensToDenom, redeemTokens, vars.sumBorrowPlusEffects);
// borrow effect
// sumBorrowPlusEffects += oraclePrice * borrowAmount
vars.sumBorrowPlusEffects = mul_ScalarTruncateAddUInt(vars.oraclePrice, borrowAmount, vars.sumBorrowPlusEffects);
}
}
// sumCollateral += tokensToDenom * cTokenBalance
if (vars.sumCollateral > vars.sumBorrowPlusEffects) {
return (Error.NO_ERROR, vars.sumCollateral - vars.sumBorrowPlusEffects, 0);
} else {
return (Error.NO_ERROR, 0, vars.sumBorrowPlusEffects - vars.sumCollateral);
}
}
因此,如果项目在计算抵押品价值时所采用的预言机的喂价机制容易被操控,则很容易借出超预期的代币。
举个例子,在 Compound 分叉项目 Lodestar Finance 被黑的事件中,预言机获取抵押品 plvGLP 代币价格的方式是先将 plvGLP 合约中 plsGLP 代币的数量(totalAssets) 除以 plvGLP 的总供应量(totalSupply) 计算出兑换率,再将兑换率乘上 GLP 代币的价格计算出 plvGLP 代币的价格。
function getPlutusExchangeRate() public view returns (uint256) {
//retrieve total assets from plvGLP contract
uint256 totalAssets = plvGLPInterface(plvGLP).totalAssets();
//retrieve total supply from plvGLP contract
uint256 totalSupply = EIP20Interface(plvGLP).totalSupply();
//plvGLP/GLP Exchange Rate = Total Assets / Total Supply
uint256 exchangeRate = (totalAssets * BASE) / totalSupply;
return exchangeRate;
}
function getPlvGLPPrice() public view returns (uint256) {
uint256 exchangeRate = getPlutusExchangeRate();
uint256 glpPrice = getGLPPrice();
uint256 price = (exchangeRate * glpPrice) / BASE;
return price;
}
而 plvGLP 有一个捐赠的功能,允许用户捐赠 sGLP 为 plvGLP 代币合约铸造相应的 plsGLP 代币。
function donate(uint256 _assets) external {
sGLP.safeTransferFrom(msg.sender, staker, _assets);
ITokenMinter(minter).mint(vault, _assets);
}
所以攻击者可以先利用闪电贷在 Lodestar Finance 市场中创建大量 plvGLP 抵押品头寸,之后在 GMX 上利用闪电贷大量铸造 sGLP,再通过 donate 函数为 plvGLP 合约铸造 plsGLP 代币以增加 totalAssets 的值。随着总资产的增加,plvGLP 的汇率会变大,导致 plvGLP 代币的价格瞬时急速上涨,从而可以在市场上借出超出预期的其他代币。
可以参考 Lodestar Finance 被黑的交易:https://arbiscan.io/tx/0xc523c6307b025ebd9aef155ba792d1ba18d5d83f97c7a846f267d3d9a3004e8c
此外还需注意的是,Compound Finance 或其分叉项目也会采用链下预言机例如 ChainLink 或者 CoinBase 来获取抵押品的价格。如果遇到市场剧烈波动的情况,可能会导致链下价格与链上出现价差而危害项目的资金安全。
例如 LUNA 代币的价格由于市场原因而急速暴跌,而 Compound Finance 的分叉协议 Venus Protocol 和 Blizz Finance 都使用 Chainlink 预言机作为喂价来源来计算抵押品的价值,其中对 LUNA 代币的最低价格(minAnswer) 进行了硬编码,其值为 0.10 美元。
当 LUNA 代币的价格跌破 0.1 美元时(例如 0.001 美元),任何人都可以按市场价格购买大量 LUNA,并将其作为抵押品(价值 0.10 美元)从平台借出其他资产。
审计要点:在审计时,需要关注计算抵押品价值时采用的预言机喂价机制是否容易被外部操控,可以建议项目方采用多种价格来源进行综合评估,以规避单一价格来源造成的风险。
4.4 多入口点代币导致的汇率操控风险
在 Compound 的代码中有一个名为 sweepToken 的函数,其作用是为了让不小心将代币转入到合约的用户能够取出这些代币。旧版本的代码如下,这个函数有一个重要的安全检查:传入的 token 参数不能是合约的底层资产代币。
function sweepToken(EIP20NonStandardInterface token) override external {
require(address(token) != underlying, "CErc20::sweepToken: can not sweep underlying token");
uint256 balance = token.balanceOf(address(this));
token.transfer(admin, balance);
}
然而,假如某个 cToken 市场的底层资产代币存在多个入口点合约(通过多个合约地址能访问同一底层余额,外部交互影响所有入口点的余额,这是一种早期的类似代理的模式),攻击者则可以调用 sweepToken 函数通过传入与 underlying 不同的入口点合约,将合约中的底层资产代币转出。
下面以 TUSD 为例,其拥有两个入口点合约,辅助入口点合约 0x8dd5fbce 会将任何的调用(例如 transfer 或者 balanceOf)转发到主合约,这意味着与其中任何一个合约的交互会影响两个合约中的余额数据(即两个不同的合约共用相同的余额数据)。
此时假设市场中设置的底层代币地址是 TUSD 的主合约地址,那么我们可以在调用 sweepToken 函数时将辅助入口点合约地址 0x8dd5fbce 作为传入的 token 参数,则可以成功通过检查 address(token) != underlying,之后合约会将其中全部的底层资产代币 TUSD 转移到管理员地址。
而 TUSD / cTUSD 的兑换率会受到 cTUSD 合约中底层资产代币 TUSD 数量的影响,当 TUSD 被全部转移到管理员地址后,TUSD / cTUSD 的汇率会瞬间暴降。此时攻击者可以以极低的兑换率去清算其他用户或者在借款之后偿还少于预期的代币数量来获利。
值得一提的是,Compound V2 的最新版本代码中对 sweepToken 函数添加了权限验证,保证只能由管理者角色来调用该合约,并且已经移除了所有存在多入口点代币的市场。
function sweepToken(EIP20NonStandardInterface token) override external {
require(msg.sender == admin, "CErc20::sweepToken: only admin can sweep tokens");
require(address(token) != underlying, "CErc20::sweepToken: can not sweep underlying token");
uint256 balance = token.balanceOf(address(this));
token.transfer(admin, balance);
}
审计要点:在审计时,对于转移合约内代币的功能,需要考虑到多入口点代币存在的场景对项目造成的影响,可以建议项目方不采用多入口点代币或者验证代币转移前后合约中的底层资产代币数量是否会有变化,并对相关的功能做好权限的检查。
4.5 新旧版本合约代码的兼容性问题
如果在 Compound Finance V2 分叉项目中,某个核心合约的代码分叉的是新版本的 Compound Finance V2 代码,而与其交互的某个其他合约采用的却是旧的代码版本,那么可能会出现兼容性的问题。
例如旧版本的 cToken 使用的 InterestRateModel 合约中获取借款利率的函数 getBorrowRate 的返回值是两个 uint 类型的值,而在新版本的 InterestRateModel 函数中,getBorrowRate 函数只会返回一个 uint 类型的值。
// 旧版本的 InterestRateModel 代码
function getBorrowRate(uint cash, uint borrows, uint reserves) external view returns (uint, uint);
// 新版本的 InterestRateModel 代码
function getBorrowRate(uint cash, uint borrows, uint reserves) virtual external view returns (uint);
但是在 Compound Finance V2 分叉项目 Percent Finance 中,项目方使用的是旧版本的 cToken 合约代码,而 InterestRateModel 合约却是采用的新的版本,这就导致了 cToken 中的 accrueInterest 函数调用 getBorrowRate 函数时会失败。而 accrueInterest 函数在提现和借贷中都有使用到,最终使得提现和借贷功能均无法正常进行,合约中的资金被彻底锁住。
// 旧版本的 cToken 代码
function accrueInterest() public returns (uint) {
AccrueInterestLocalVars memory vars;
/* Calculate the current borrow interest rate */
(vars.opaqueErr, vars.borrowRateMantissa) = interestRateModel.getBorrowRate(getCashPrior(), totalBorrows, totalReserves);
...
}
审计要点:在审计时,需要关注更新的代码中的合约接口、状态变量、函数签名和事件的变更是否会破坏现有系统的正常运行,确保所有合约代码版本更新的一致性或者保证更新后的代码能够兼容旧版本的代码。
4.6 多链部署导致的硬编码问题
在 Compound Finance V2 的代码中,常量 blocksPerYear 代表每年产出区块的预估数量,其值在利率模型合约中被硬编码为 2102400 ,这是因为以太坊的平均出块时间为 15 秒。
contract WhitePaperInterestRateModel is InterestRateModel {
...
/**
* @notice The approximate number of blocks per year that is assumed by the interest rate model
*/
uint public constant blocksPerYear = 2102400;
...
}
然而不同链的区块时间不一定相同,同样全年产出的大致区块数量也不一定是相同的。如果某个 Compound 的分叉项目在其他链上部署,但是却没有根据不同链的情况修改硬编码的值,那么可能会造成利率最后计算的结果超出预期。这是因为 blocksPerYear 的值会影响到 baseRatePerBlock 和 multiplierPerBlock 的值,而 baseRatePerBlock 和 multiplierPerBlock 最终会影响到借款利率。
contract WhitePaperInterestRateModel is InterestRateModel {
...
constructor(uint baseRatePerYear, uint multiplierPerYear) public {
baseRatePerBlock = baseRatePerYear / blocksPerYear;
multiplierPerBlock = multiplierPerYear / blocksPerYear;
emit NewInterestParams(baseRatePerBlock, multiplierPerBlock);
}
...
function getBorrowRate(uint cash, uint borrows, uint reserves) override public view returns (uint) {
uint ur = utilizationRate(cash, borrows, reserves);
return (ur * multiplierPerBlock / BASE) + baseRatePerBlock;
}
}
例如 BSC 链的出块时间是 3 秒,那么全年预估的出块数量(blocksPerYear) 应该为 10512000。如果在部署前没有修改 blocksPerYear 的值,那么会导致最后计算出来的借款利率比预期高出五倍。
审计要点:在审计时,要关注项目合约中硬编码的常量或变量在不同链的特性下是否会造成非预期的结果,建议项目方根据不同链的情况来正确地修改其值。
其他
除了上面提到的这些主要关注的问题,Compound V2 的分叉项目通常会根据项目团队的设计来修改部分业务逻辑,例如添加与外部第三方协议进行交互的代码。这需要在审计时根据其具体的业务逻辑和设计需求去评估是否会对 Compound Finance V2 本身的核心借贷模型以及项目造成影响。
写在最后
希望这份 Compound Finance V2 及其 Fork 项目的安全审计手册能帮助大家在审计时更好地理解和评估此类复杂系统的安全性,随着技术的迭代更新,本手册也会随之更新和完善。
参考:
[1] https://github.com/YAcademy-Residents/defi-fork-bugs
[2] https://medium.com/chainsecurity/trueusd-compound-vulnerability-bc5b696d29e2
[3] https://github.com/code-423n4/2023-05-venus-findings/issues/559
[4] https://learnblockchain.cn/article/2593
[5] https://github.com/compound-finance/compound-protocol
作者 | 九九
编辑 | Liz
往期回顾
慢雾导航
慢雾科技官网
https://www.slowmist.com/
慢雾区官网
https://slowmist.io/
慢雾 GitHub
https://github.com/slowmist
Telegram
https://t.me/slowmistteam
https://twitter.com/@slowmist_team
Medium
https://medium.com/@slowmist
知识星球
https://t.zsxq.com/Q3zNvvF