本手冊提供詳盡的安全審計指南以説明理解和評估此類複雜系統的安全性。

  作者:九九,慢霧安全團隊

 編輯:Liz

引言

隨著 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 合約負責獲取外部市場價格資訊,並將其轉換為系統內部使用的數值,這對於準確計算使用者的頭寸價值至關重要。

   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

免責聲明:作為區塊鏈資訊平臺,本站所發佈文章僅代表作者及嘉賓個人觀點,與 Web3Caff 立場無關。 文章內的資訊僅供參考,均不構成任何投資建議及要約,並請您遵守所在國家或地區的相關法律法規。