Back to Blog

Radiant V2 安全測試報告

Code Auditing
March 23, 2023
33 min read

報告清單

項目 描述
客戶 Radiant Capital
目標 Radiant V2

版本歷史

版本 日期 描述
1.0 2023 年 3 月 15 日 第一版
2.0 2023 年 3 月 21 日 第二版

1. 簡介

1.1 關於安全性測試

我們受 Radiant Capital 之邀,作為紅隊對 Radiant V2 的智慧合約進行安全性測試,以識別潛在風險。作為一個負責任的團隊,Radiant Capital 非常重視安全性。因此,儘管他們的智慧合約已由多家安全公司審計 ^1,團隊仍決定投入更多精力來確保這些智慧合約的安全性。

請注意,安全性測試與安全性審計在目標和要求上皆有所不同。具體而言,安全性測試旨在透過模仿攻擊者來破壞程式/協議,進而發現額外/異常的漏洞點;而安全性審計則旨在透過枚舉可能的攻擊,提供相對全面的安全性檢查。因此,由於時間和資源的限制,安全性測試可能無法涵蓋某些安全性審計所能識別的複雜邏輯漏洞。

1.2 關於目標合約

資訊 描述
類型 智慧合約
語言 Solidity
方法 靜態分析、動態分析、半自動與手動驗證

目標儲存庫為 Radiant_v2.1.1。安全性測試期間的提交 SHA 值如下所示。我們的報告僅負責初始版本(即版本 1),以及後續版本中用於修復報告問題的新程式碼。

請注意,本報告僅涵蓋該儲存庫 radiant_v2.1.1/contracts 資料夾下的智慧合約,包括:

  • bounties
  • deployments
  • flashloan
  • leverage
  • lock
  • oracles
  • staking
  • zap
  • eligibility
  • misc
  • oft
  • protocol
  • stargate

版本 8 更新後,本安全性測試涵蓋的檔案包括:

  • lending/AaveOracle.sol
  • lending/AaveProtocolDataProvider.sol
  • lending/ATokensAndRatesHelper.sol
  • lending/StableAndVariableTokensHelper.sol
  • lending/UiPoolDataProviderV2V3.sol
  • lending/UiPoolDataProvider.sol
  • lending/WETHGateway.sol
  • lending/WalletBalanceProvider.sol
  • lending/configuration
  • lending/flashloan
  • lending/lendingpool
  • lending/tokenization
  • radiant/accessories
  • radiant/eligibility
  • radiant/oracles
  • radiant/staking
  • radiant/token
  • radiant/zap

1.3 安全模型

為了評估風險,我們遵循業界與學術界廣泛採用的標準或建議,包括 OWASP 風險評級方法 ^2 和常見缺陷枚舉 ^3。風險的總體嚴重程度可能性影響決定。具體而言,可能性用於估計攻擊者發現並利用特定漏洞的可能性,而影響則用於衡量成功利用後的後果。

在本報告中,可能性和影響均分為兩個等級,即,其實際組合如表 1.1 所示。

表 1.1:漏洞嚴重程度分類
表 1.1:漏洞嚴重程度分類

因此,本報告中測量的嚴重程度分為三類:。為了完整性,未定也用於涵蓋無法明確判定風險的情況。

此外,所發現項目的狀態將屬於以下四類之一:

  • 未定 尚未回應。

  • 已獲悉 客戶已收到該項目,但尚未確認。

  • 已確認 客戶已認可該項目,但尚未修復。

  • 已修復 客戶已確認並修復了該項目。

2. 自動化安全性測試

2.1 自動化靜態安全性測試

我們使用基於 Slither 的內部靜態分析工具來檢查漏洞的存在。經人工檢查結果後,未發現任何問題。詳細測試結果可在附錄中的表 4.1 中找到。

2.2 自動化動態安全性測試

我們利用模糊測試 (Fuzzing) 技術來測試目標合約的穩健性、可靠性和精確度。具體而言,模糊測試過程中的初始種子是根據函數語義和合約測試腳本確定的。為了模擬鏈上環境,我們還維護了一組與合約 LendingPool 和 MultiFeeDistribution 互動過的地址。

我們的模糊測試器在生成交易序列時也會考慮函數語義。例如,合約 MultiFeeDistribution 中的 stake 函數和合約 LendingPool 中的 deposit 函數更有可能在序列中優先被呼叫。對函數參數和序列的變異是基於合約程式碼覆蓋率來引導的。如果某個參數或序列達到了更高的程式碼覆蓋率,它在下一輪模糊測試中被變異的優先級就會更高。為了探索受幻數約束的路徑,我們在執行時收集從儲存中讀取的值(即 SLOAD 指令),並在變異過程中利用它們來生成函數參數。

我們總共生成了 100,000 個測試案例,並利用了 31 個預言機來檢測是否發生了故障。每個測試案例包含 30 筆指定順序的交易。最終,我們發現了一個關鍵問題(即第 3.2.6 節),該問題也在我們的手動安全性測試過程中被發現。詳細測試結果可在附錄中的表 4.2、4.3 和 4.4 中找到。

3. 手動安全性測試

我們投入人力來理解不同模組之間的整體設計和互動,並根據我們對既往研究和經驗所累積的潛在攻擊面的知識,進行安全性測試。

我們總共發現了十七個潛在問題。此外,我們還有三個建議和一個備註,如下:

  • 高風險:2

  • 中風險:8

  • 低風險:7

  • 建議:3

  • 備註:1

ID 嚴重程度 描述 類別 狀態
1 沒有用於重置函數指標的預留介面 軟體安全 已修復
2 預言機計算不當 DeFi 安全 已修復
3 透過 BaseBounty 耗盡資金的潛在風險 DeFi 安全 已修復
4 潛在的無效發放計劃 DeFi 安全 已修復
5 可跳過的發放計劃 DeFi 安全 已確認
6 遷移期間可變更匯率 DeFi 安全 已修復
7 _transfer() 實作不當 (I) DeFi 安全 已修復
8 UniV2TwapOracle 中缺乏對週期的檢查 DeFi 安全 已修復
9 不可退還的零頭代幣 (Dust Tokens) DeFi 安全 已修復
10 _transfer() 實作不當 (II) DeFi 安全 已修復
11 可操縱的複利獎勵 DeFi 安全 已修復
12 setLeverager() 缺乏存取控制 DeFi 安全 已修復
13 addLiquidityWETHOnly() 中沒有滑點檢查 DeFi 安全 已確認
14 loopETH() 中缺乏對 borrowRatio 的檢查 DeFi 安全 已修復
15 setPoolIDs() 中缺乏對資產與與池 ID 長度的檢查 DeFi 安全 已修復
16 addBountyContract() 缺乏鑄造許可權撤銷 DeFi 安全 已確認
17 鑄造者只能被分配一次 DeFi 安全 已確認
18 - Gas 優化 (Mfd 中的 zapVestingToLp()) 建議 已修復
19 - BountyManager 中的非空賞金儲備 建議 已修復
20 - requiredUsdValue() 中的命名不一致 建議 已確認
21 - 已棄用的 MFDPlus 備註 備註 已確認

詳細資訊如下文各節所示。

3.1 軟體安全

3.1.1 潛在問題 1:沒有用於重置函數指標的預留介面

項目 描述
嚴重程度
狀態 版本 7 已修復
引入版本 版本 1

描述 三個函數 getLpMfdBounty()、getChefBounty() 和 getAutoCompoundBounty() 在合約 BountyManager 中透過函數指標呼叫。同時,從 OwnableUpgradable 的繼承表明該合約將是代理的實作。這顯示該實作合約將來可以升級,從而引發與函數指標相關的問題。

function initialize(
        address _rdnt,
        address _weth,
        address _lpMfd,
        address _mfd,
        address _chef,
        address _priceProvider,
        address _eligibilityDataProvider,
        uint256 _hunterShare,
        uint256 _baseBountyUsdTarget,
        uint256 _maxBaseBounty,
        uint256 _bountyBooster
    ) external initializer {
        require(_rdnt != address(0));
        require(_weth != address(0));
        require(_lpMfd != address(0));
        require(_mfd != address(0));
        require(_chef != address(0));
        require(_priceProvider != address(0));
        require(_eligibilityDataProvider != address(0));
        require(_hunterShare <= 10000);
        require(_baseBountyUsdTarget != 0);
        require(_maxBaseBounty != 0);
 
        rdnt = _rdnt;
        weth = _weth;
        lpMfd = _lpMfd;
        mfd = _mfd;
        chef = _chef;
        priceProvider = _priceProvider;
        eligibilityDataProvider = _eligibilityDataProvider;
 
        HUNTER_SHARE = _hunterShare;
        baseBountyUsdTarget = _baseBountyUsdTarget;
        bountyBooster = _bountyBooster;
        maxBaseBounty = _maxBaseBounty;
 
        bounties[1] = getLpMfdBounty;
        bounties[2] = getChefBounty;
        bounties[3] = getAutoCompoundBounty;
        bountyCount = 3;
 
        slippageLimit = 10;
        minDLPBalance = uint256(5).mul(10 ** 18);
 
 
        __Ownable_init();
        __Pausable_init();
    } 

列表 3.1:BountyManager.sol

影響 當上述三個函數的偏移量發生變化時,函數指標無法按預期工作,合約的整體邏輯可能會被更改。

建議 合約應提供用於重置函數指標的介面。

3.2 DeFi 安全

3.2.1 潛在問題 2:預言機計算不當

項目 描述
嚴重程度
狀態 版本 11 已修復
引入版本 版本 1 與版本 4

描述 合約 ComboOracle 中的 consult() 函數用於計算來自多個來源的平均價格。在版本 1 的實現中,它使用算術平均數來計算最終價格,這可能會因影響其中一個來源預言機而被操縱。

function consult() public view override returns (uint256 price) {
        require(sources.length != 0);

        uint256 sum;
        for (uint256 i = 0; i < sources.length; i++) {
            uint256 price = sources[i].consult();
            require(price != 0, "source consult failure");
            sum = sum.add(price);
        }
        price = sum.div(sources.length);
    }

列表 3.2:ComboOracle.sol

在版本 4 的實作中,當平均價格大於最低價格 × 1.025 時,將返回最低價格。然而,如果其中一個來源預言機返回的結果異常過低,回傳值仍然可以被操縱。

/**
    * @notice Calculated price
    * @return price Average price of several sources.
    */
   function consult() public view override returns (uint256 price) {
       require(sources.length != 0);

       uint256 sum;
       uint256 lowestPrice;
       for (uint256 i = 0; i < sources.length; i++) {
           uint256 price = sources[i].consult();
           require(price != 0, "source consult failure");
           if (lowestPrice == 0) {
               lowestPrice = price;
           } else {
               lowestPrice = lowestPrice > price ? price : lowestPrice;
           }
           sum = sum.add(price);
       }
       price = sum.div(sources.length);
       price = price > ((lowestPrice * 1025) / 1000) ? lowestPrice : price;
   }

列表 3.3:ComboOracle.sol

影響 ComboOracle 返回的價格可以被操縱,從而允許攻擊者從中獲利。

建議 我們建議使用中位數而不是平均值。如果只有兩個來源預言機且出現較大差異,則在平均價格遠大於最低價格時撤銷交易是更合理的方案。

反饋 將只會有兩個來源預言機。如果出現較大差異,我們將使用 OZ Defender Sentinel 來暫停相關合約。

備註 合約 ComboOracle 已移除,不再使用。

3.2.2 潛在問題 3:透過 BaseBounty 耗盡資金的潛在風險

項目 描述
嚴重程度
狀態 版本 4 已修復
引入版本 版本 1

描述 使用者可以鎖定代幣(即 RDNT)一段固定時間以獲得獎勵。當鎖定期結束後,如果該使用者啟用了 AutoRelock,其他使用者可以呼叫 executeBounty() 函數為此使用者重新鎖定代幣以獲得 BaseBounty。在重新鎖定過程中,過期的鎖定將在內部函數 _cleanWithdrawableLocks() 中被清除並重新質押到池中。然而,有一個變數 maxLockWithdrawPerTxn 限制了每次交易可以清除的最大鎖定數量。在這種情況下,即使在 executeBounty() 函數執行後,未清除的過期鎖定可能仍然存在。這會進一步繞過 MFDPlus 合約中 claimBounty() 函數第 106 行的檢查。issueBaseBounty 將被設定為 true 並返回。

**
    * @notice Withdraw all lockings tokens where the unlock time has passed
    */
   function _cleanWithdrawableLocks(
       address user,
       uint256 totalLock,
       uint256 totalLockWithMultiplier
   ) internal returns (uint256 lockAmount, uint256 lockAmountWithMultiplier) {
       LockedBalance[] storage locks = userLocks[user];

       if (locks.length != 0) {
           uint256 length = locks.length <= maxLockWithdrawPerTxn ? locks.length : maxLockWithdrawPerTxn;
           for (uint256 i = 0; i < length; ) {
               if (locks[i].unlockTime <= block.timestamp) {
                   lockAmount = lockAmount.add(locks[i].amount);
                   lockAmountWithMultiplier = lockAmountWithMultiplier.add(
                       locks[i].amount.mul(locks[i].multiplier)
                   );
                   locks[i] = locks[locks.length - 1];
                   locks.pop();
                   length = length - 1;
               } else {
                   i = i + 1;
               }
           }
           if (locks.length == 0) {
               lockAmount = totalLock;
               lockAmountWithMultiplier = totalLockWithMultiplier;
               delete userLocks[user];

               userlist.removeFromList(user);
           }
       }
   }

列表 3.4:MultiFeeDistribution.sol

具體而言,攻擊者可以用相同的到期時間多次質押 1 wei 代幣,這遠大於 maxLockWithdrawPerTxn。之後,攻擊者可以將操作設定為 getLpMfdBounty 並反覆呼叫 executeBounty()。由於清除鎖定的數量受到 maxLockWithdrawPerTxn 的限制,BountyManager 合約中的 BaseBounty 可能會被攻擊者耗盡。

影響 攻擊者可以在一次交易中耗盡 BountyManager 合約中的所有資金,導致設計的賞金機制中斷。

建議 確保函數 _cleanWithdrawableLocks() 可以清除所有過期的鎖定,並在 _stake() 函數中設定最低質押金額。

3.2.3 潛在問題 4:潛在的無效發放計劃

項目 描述
嚴重程度
狀態 版本 10 已修復
引入版本 版本 1

描述 在合約 ChefIncentivesController 中,setEmissionSchedule() 函數由所有者呼叫,用於設定不同的獎勵費率計劃。在這種情況下,每個計劃的開始時間 (_startTimeOffsets[i] + startTime) 應經驗證大於當前時間戳記。然而,它僅檢查 _startTimeOffsets 中的第一個元素,這是不夠的。此外,當 _startTimeOffsets[i] 被新增到 emissionSchedule 時,它會從 uint256 轉換為 uint128,如果原始輸入太大,可能會被截斷。

function setEmissionSchedule(
        uint256[] calldata _startTimeOffsets,
        uint256[] calldata _rewardsPerSecond
    ) external onlyOwner {
        uint256 length = _startTimeOffsets.length;
        require(length > 0 && length == _rewardsPerSecond.length, "empty or mismatch params");
        if (startTime > 0) {
            require(_startTimeOffsets[0] > block.timestamp.sub(startTime), "invalid start time");
        }
 
        for (uint256 i = 0; i < length; i++) {
            emissionSchedule.push(
                EmissionPoint({
                    startTimeOffset: uint128(_startTimeOffsets[i]),
                    rewardsPerSecond: uint128(_rewardsPerSecond[i])
                })
            );
        }
        emit EmissionScheduleAppended(_startTimeOffsets, _rewardsPerSecond);
    } 

列表 3.5:ChefIncentivesController.sol

影響 如果 _startTimeOffsets 不按升序排列,一些承諾的獎勵將不會發放給使用者。如果 _startTimeOffsets[i] 超出了 uint128 的範圍,則會增加一個無效的發放計劃。

建議 確保 _startTimeOffsets 按升序排列,且所有元素都在 uint128 範圍內。

3.2.4 潛在問題 5:可跳過的發放計劃

項目 描述
嚴重程度
狀態 已確認
引入版本 版本 1

描述 在合約 ChefIncentivesController 中,函數 setScheduleRewardsPerSecond() 將遍歷 emissionSchedule 以定位已開始且索引最大的目標計劃,並相應地更新獎勵費率。然而在這種情況下,某些發放計劃可能會被跳過。

function setScheduledRewardsPerSecond() internal {
		if (!persistRewardsPerSecond) {
			uint256 length = emissionSchedule.length;
			uint256 i = emissionScheduleIndex;
			uint128 offset = uint128(block.timestamp.sub(startTime));
			for (; i < length && offset >= emissionSchedule[i].startTimeOffset; i++) {}
			if (i > emissionScheduleIndex) {
				emissionScheduleIndex = i;
				_massUpdatePools();
				rewardsPerSecond = uint256(emissionSchedule[i - 1].rewardsPerSecond);
			}
		}
	}

列表 3.6:ChefIncentivesController.sol

影響 如果函數 setScheduledRewardsPerSecond() 長時間未被呼叫,一些承諾的獎勵可能無法發放給使用者。

建議 函數 setScheduledRewardsPerSecond() 在函數 claim() 和 _handleActionAfterForToken() 內被呼叫,因此發放計劃被跳過的唯一方式是在發放週期內沒有人與協議互動。

3.2.5 潛在問題 6:遷移期間可變更匯率

項目 描述
嚴重程度
狀態 版本 5 已修復
引入版本 版本 1

描述 合約 Migration 的實作允許使用者用指定的 exchangeRate 將 tokenV1 交換為 tokenV2。然而,在遷移過程中,這個 exchangeRate 仍然可以由所有者透過 setExchangeRate() 函數進行調整。

/**
    * @notice Migrate from V1 to V2
    * @param amount of V1 token
    */
   function exchange(uint256 amount) external whenNotPaused {
       uint256 v1Decimals = tokenV1.decimals();
       uint256 v2Decimals = tokenV2.decimals();

       uint256 outAmount = amount.mul(1e4).div(exchangeRate).mul(10**v2Decimals).div(10**v1Decimals);
       tokenV1.safeTransferFrom(_msgSender(), address(this), amount);
       tokenV2.safeTransfer(_msgSender(), outAmount);

       emit Migrate(_msgSender(), amount, outAmount);
   }

列表 3.7:Migration.sol

影響 如果匯率在遷移過程中發生變化,對其他使用者將是不公平的。

建議 一旦遷移開始,匯率應固定下來。

3.2.6 潛在問題 7:_transfer() 實作不當 (I)

項目 描述
嚴重程度
狀態 版本 7 已修復
引入版本 版本 1

描述 在合約 IncentivizedERC20 中,函數 _transfer() 沒有考慮傳送者和接收者可以是同一個帳戶(即所謂的自我轉帳)的情況。具體來說,如果傳送者等於接收者,在更新接收者餘額時,傳送者的餘額會被覆蓋。在這種情況下,駭客可以透過重複向自己的帳戶進行轉帳而無限增加自己的餘額。

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: transfer from the zero address');
        require(recipient != address(0), 'ERC20: transfer to the zero address');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
        uint256 recipientBalance = _balances[recipient].add(amount);
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

列表 3.8:IncentivizedERC20.sol

影響 代幣可以被無限鑄造。

建議 正確實作 _transfer() 函數。例如,OpenZeppelin 的 ERC20 標準 _transfer() 實作。

_balances[sender] = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
_balances[recipient] = _balances[recipient].add(amount);

列表 3.9:ERC20.sol in OpenZeppelin

3.2.7 潛在問題 8:UniV2TwapOracle 中缺乏對週期的檢查

項目 描述
嚴重程度
狀態 版本 9 已修復
引入版本 版本 1

描述 在合約 UniV2TwapOracle 中,屬性 _period 在 initialize() 和 setPeriod() 函數中未經驗證。

function initialize(
        address _pair,
        address _rdnt,
        address _ethChainlinkFeed,
        uint _period,
        uint _consultLeniency,
        bool _allowStaleConsults
    ) external initializer {
        __Ownable_init();

        pair = IUniswapV2Pair(_pair);
        token0 = pair.token0();
        token1 = pair.token1();
        price0CumulativeLast = pair.price0CumulativeLast(); // Fetch the current accumulated price value (1 / 0)
        price1CumulativeLast = pair.price1CumulativeLast(); // Fetch the current accumulated price value (0 / 1)
        uint112 reserve0;
        uint112 reserve1;
        (reserve0, reserve1, blockTimestampLast) = pair.getReserves();
        require(reserve0 != 0 && reserve1 != 0, 'UniswapPairOracle: NO_RESERVES'); // Ensure that there's liquidity in the pair

        PERIOD = _period;
        CONSULT_LENIENCY = _consultLeniency;
        ALLOW_STALE_CONSULTS = _allowStaleConsults;

        baseInitialize(_rdnt, _ethChainlinkFeed);
    }

    function setPeriod(uint _period) external onlyOwner {
        PERIOD = _period;
    }

列表 3.10:UniV2TwapOracle.sol

影響 在這種情況下,如果 _period 太小,預言機可能會返回意外的值。

建議 在 initialize 和 setPeriod 函數中為 _period 設定最低限制。

3.2.8 潛在問題 9:不可退還的零頭代幣

項目 描述
嚴重程度
狀態 版本 5 已修復
引入版本 版本 1

描述 在合約 UniswapPoolHelper 中,zapWETH() 函數旨在協助使用者將 WETH 代幣轉換為 LP 代幣。它將呼叫 addLiquidityWETHOnly() 函數來為 LP 代幣增加流動性。在此過程中,可能會存在應退回給使用者的零頭代幣 (Dust Tokens)。然而,UniswapPoolHelper 並未實作處理這些零頭代幣的功能。

function zapWETH(uint256 amount)
    public
    returns (uint256 liquidity)
{
    IWETH WETH = IWETH(wethAddr);
    WETH.transferFrom(msg.sender, address(liquidityZap), amount);
    liquidity = liquidityZap.addLiquidityWETHOnly(amount, address(this));
    IERC20 lp = IERC20(lpTokenAddr);
    
    liquidity = lp.balanceOf(address(this));
    lp.safeTransfer(msg.sender, liquidity);
}

列表 3.11:UniswapPoolHelper.sol

影響 零頭代幣將保留在合約中,其他人可以透過 zapTokens(0,0) 函數提取。

建議 在增加流動性後實作退回零頭代幣的函數。

3.2.9 潛在問題 10:_transfer() 實作不當 (II)

項目 描述
嚴重程度
狀態 版本 9 已修復
引入版本 版本 7

描述 在合約 IncentivizedERC20 中,函數 _transfer() 將呼叫 handleActionAfter() 函數來更新合約 ChefIncentivesController 中使用者的狀態。然而,如果傳送者等於接收者,參數 senderBalance 就不會被更新,這是錯誤的。

function _transfer(
        address sender,
        address recipient,
        uint256 amount
      ) internal virtual {
        require(sender != address(0), 'ERC20: transfer from the zero address');
        require(recipient != address(0), 'ERC20: transfer to the zero address');
    
        _beforeTokenTransfer(sender, recipient, amount);
    
        uint256 senderBalance = _balances[sender].sub(amount, 'ERC20: transfer amount exceeds balance');
    
        if (address(_getIncentivesController()) != address(0)) {
          // uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionBefore(sender);
          if (sender != recipient) {
            _getIncentivesController().handleActionBefore(recipient);
          }
        }
    
        _balances[sender] = senderBalance;
        uint256 recipientBalance = _balances[recipient].add(amount);
        _balances[recipient] = recipientBalance;
    
        if (address(_getIncentivesController()) != address(0)) {
          uint256 currentTotalSupply = _totalSupply;
          _getIncentivesController().handleActionAfter(sender, senderBalance, currentTotalSupply);
          if (sender != recipient) {
            _getIncentivesController().handleActionAfter(recipient, recipientBalance, currentTotalSupply);
          }
        }
      }

列表 3.12:IncentivizedERC20.sol

影響 當使用者轉帳給自己時,他們在合約 ChefIncentivesController 中的狀態將無法正確更新,這將為獎勵帶來進一步的問題。

建議 在 handleActionAfter() 函數中修正 senderBalance。

3.2.10 潛在問題 11:可操縱的複利獎勵

項目 描述
嚴重程度
狀態 版本 10 已修復
引入版本 版本 5

描述 在 MFDPlus 合約中,函數 _convertPendingRewardsToWeth() 透過 Uniswap 路由器將使用者的獎勵兌換為 WETH 以進行重新鎖定。然而,兌換後沒有進行滑點檢查。

IERC20(underlying).safeApprove(uniRouter, removedAmount);
    uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // slippage handled after this function
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

列表 3.13:MFDPlus.sol

影響 攻擊者可以搶先交易 (Front-run) 來操縱價格並獲利。

建議 在 claimCompound() 函數中增加滑點檢查。

3.2.11 潛在問題 12:setLeverager() 缺乏存取控制

項目 描述
嚴重程度
狀態 版本 9 已修復
引入版本 版本 1

描述 合約 LendingPool 中的 setLeverager() 函數沒有存取控制。

uint256[] memory amounts = IUniswapV2Router02(uniRouter)
    .swapExactTokensForTokens(
         removedAmount,
         0, // slippage handled after this function
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

列表 3.14:LendingPool.sol

影響 如果 leverager 一開始未設定,攻擊者可以將 leverager 設定為任何地址,從而獲得對 depositWithAutoDLP() 函數邏輯的控制權。

建議 在 initialize() 函數中設定 leverager,或者為 setLeverager() 函數增加存取控制。

3.2.12 潛在問題 13:addLiquidityWETHOnly() 中沒有滑點檢查

項目 描述
嚴重程度
狀態 已確認
引入版本 版本 1

描述 使用者可以使用借入的 WETH 代幣(或自己的 ETH 代幣)或 MFD 合約中的歸屬 RDNT 代幣來獲得 LP 代幣(即 WETH-RDNT)。

然而,當向池中增加流動性時,所需代幣的計算是基於池中的儲備量,這可能被操縱。在這種情況下,如果使用者只有 WETH 代幣,將會呼叫 addLiquidityWETHOnly() 函數,在不檢查滑點的情況下將一半的 WETH 代幣兌換為非平衡池中的 RDNT 代幣。

function addLiquidityWETHOnly(uint256 _amount, address payable to)
    public
    returns (uint256 liquidity)
{
    require(to != address(0), "LiquidityZAP: Invalid address");
    uint256 buyAmount = _amount.div(2);
    require(buyAmount > 0, "LiquidityZAP: Insufficient ETH amount");

    (uint256 reserveWeth, uint256 reserveTokens) = getPairReserves();
    uint256 outTokens = UniswapV2Library.getAmountOut(
        buyAmount,
        reserveWeth,
        reserveTokens
    );

    _WETH.transfer(_tokenWETHPair, buyAmount);

    (address token0, address token1) = UniswapV2Library.sortTokens(
        address(_WETH),
        _token
    );
    IUniswapV2Pair(_tokenWETHPair).swap(
        _token == token0 ? outTokens : 0,
        _token == token1 ? outTokens : 0,
        address(this),
        ""
    );

    return _addLiquidity(outTokens, buyAmount, to);
}

列表 3.15:LiquidityZap.sol

function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
       require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
       require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
       uint amountInWithFee = amountIn.mul(997);
       uint numerator = amountInWithFee.mul(reserveOut);
       uint denominator = reserveIn.mul(1000).add(amountInWithFee);
       amountOut = numerator / denominator;
   }  

列表 3.16:UniswapV2Library.sol

影響 攻擊者可以搶先交易來操縱價格並獲利。

建議 在 addLiquidityWETHOnly() 函數中檢查滑點,或確保該函數只能由 UniswapPoolHelper 呼叫。

3.2.13 潛在問題 14:loopETH() 中缺乏對 borrowRatio 的檢查

項目 描述
嚴重程度
狀態 版本 10 已修復
引入版本 版本 1

描述 loopETH() 函數用於槓桿借貸,並接收一個參數 borrowRatio 來指定借貸比率。然而,在迴圈開始前並未對 borrowRatio 進行檢查。

function loopETH(
        uint256 interestRateMode,
        uint256 borrowRatio,
        uint256 loopCount
    ) external payable {
        uint16 referralCode = 0;
        uint256 amount = msg.value;
        if (IERC20(address(weth)).allowance(address(this), address(lendingPool)) == 0) {
            IERC20(address(weth)).safeApprove(address(lendingPool), type(uint256).max);
        }
        if (IERC20(address(weth)).allowance(address(this), address(treasury)) == 0) {
            IERC20(address(weth)).safeApprove(treasury, type(uint256).max);
        }

        uint256 fee = amount.mul(feePercent).div(RATIO_DIVISOR);
        _safeTransferETH(treasury, fee);
        
        amount = amount.sub(fee);

        weth.deposit{value: amount}();
        lendingPool.deposit(address(weth), amount, msg.sender, referralCode);

        for (uint256 i = 0; i < loopCount; i += 1) {
            amount = amount.mul(borrowRatio).div(RATIO_DIVISOR);
            lendingPool.borrow(address(weth), amount, interestRateMode, referralCode, msg.sender);
            weth.withdraw(amount);

            fee = amount.mul(feePercent).div(RATIO_DIVISOR);
            _safeTransferETH(treasury, fee);

            weth.deposit{value: amount.sub(fee)}();
            lendingPool.deposit(address(weth), amount.sub(fee), msg.sender, referralCode);
        }

        zapWETHWithBorrow(wethToZap(msg.sender), msg.sender);
    }

列表 3.17:Leverager.sol

影響 borrowRatio 可能高於 RATIO_DIVISOR,這與原始設計不符。

建議 確保 borrowRatio 小於或等於 RATIO_DIVISOR。

3.2.14 潛在問題 15:setPoolIDs() 中缺乏對資產與與池 ID 長度的檢查

項目 描述
嚴重程度
狀態 版本 10 已修復
引入版本 版本 1

描述 setPoolIDs() 函數允許所有者為不同資產設定不同的 poolIDs。然而,並未檢查這兩個陣列的長度是否相等。

// Set pool ids of assets
    function setPoolIDs(address[] memory assets, uint256[] memory poolIDs) external onlyOwner {
        for (uint256 i = 0; i < assets.length; i += 1) {
            poolIdPerChain[assets[i]] = poolIDs[i];
        }
        emit PoolIDsUpdated(assets, poolIDs);
    } 

列表 3.18:StarBorrow.sol

影響 資產將不會被分配到正確的 poolIDs。

建議 確保資產和 poolIDs 的長度相等。

3.2.15 潛在問題 16:addBountyContract() 缺乏鑄造許可權撤銷

項目 描述
嚴重程度
狀態 已確認
引入版本 版本 1

描述 addBountyContract() 函數用於設定新的 BountyManager。然而,原始的賞金合約仍然持有鑄造特權,這與原始設計背道而馳。

function addBountyContract(address _bounty) external onlyOwner {
       BountyManager = _bounty;
       minters[_bounty] = true;
   }

列表 3.19:Leverager.sol

影響 已過時的 BountyManager 仍然擁有鑄造特權。

建議 撤銷原始 BountyManager 合約的鑄造特權。

反饋 addBountyContract 函數只會被呼叫一次以初始化 BountyManager。

3.2.16 潛在問題 17:鑄造者只能被分配一次

項目 描述
嚴重程度
狀態 已確認
引入版本 版本 1

描述 minters 用於記錄那些擁有存取 mint() 和 addReward() 函數權限的人。然而,當其中一個鑄造者(例如 ChefIncentivesController 合約)被更新時,過時的鑄造者無法被移除。

function setMinters(address[] memory _minters) external onlyOwner {
        require(!mintersAreSet);
        for (uint256 i; i < _minters.length; i++) {
            minters[_minters[i]] = true;
        }
        mintersAreSet = true;
    }

列表 3.20:MultiFeeDistribution.sol

影響 當它們升級時,過時的鑄造者無法被移除。

建議 實作一個特權函數來修改鑄造者。

反饋 因為 BountyManager、ChefIncentivesController 和 MultiFeeDistribution 都將是可升級的,所以鑄造者始終保持相同的代理位址。

3.3 額外建議

3.3.1 潛在問題 18:Gas 優化 (Mfd 中的 zapVestingToLp())

項目 描述
狀態 版本 10 已修復
引入版本 版本 1

描述 zapVestingToLp() 函數只能由 LockZap 合約呼叫,以將使用者的鎖定收益轉出。它從索引 0 開始遍歷使用者的 earnings 陣列,並檢查 unlockTime 是否大於當前時間戳記。如果是,該收益將從陣列中移除並轉出。然而,由於陣列中的 unlockTime 隨索引增加,從陣列末尾向開頭進行疊代會更有效率。如果 unlockTime 小於當前時間戳記,則可以中斷迴圈。

function zapVestingToLp(address _user)
        external
        override
        returns (uint256 zapped)
    {
        require(msg.sender == lockZap);

        LockedBalance[] storage earnings = userEarnings[_user];
        uint256 length = earnings.length;

        for (uint256 i = 0; i < length; ) {
            // only vesting, so only look at currently locked items
            if (earnings[i].unlockTime > block.timestamp) {
                zapped = zapped.add(earnings[i].amount);
                // remove + shift array size
                earnings[i] = earnings[earnings.length - 1];
                earnings.pop();
                length = length.sub(1);
            } else {
                i = i.add(1);
            }
        }

        rdntToken.safeTransfer(lockZap, zapped);

        Balances storage bal = balances[_user];
        bal.earned = bal.earned.sub(zapped);
        bal.total = bal.total.sub(zapped);

        return zapped;
    }

列表 3.21:MultiFeeDistribution.sol

建議 從 earnings 的末尾向開頭進行疊代。如果 unlockTime 小於當前時間戳記,則可以中斷迴圈。

3.3.2 潛在問題 19:BountyManager 中的非空賞金儲備

項目 描述
狀態 版本 10 已修復
引入版本 版本 1

描述 在 _sendBounty() 函數中,如果 BountyManager 合約中的 RDNT 代幣不足以進行轉帳,將觸發 BountyReseveEmpty() 事件,且合約將被暫停。然而,仍有可能剩餘一些 RDNT 代幣,這與觸發的事件不一致。

function _sendBounty(address _to, uint256 _amount)
		internal
		returns (uint256)
	{
		if (_amount == 0) {
			return 0;
		}

		uint256 bountyReserve = IERC20(rdnt).balanceOf(address(this));
		if(_amount > bountyReserve) {
			emit BountyReserveEmpty(bountyReserve);
			_pause();
		} else {
			IERC20(rdnt).safeTransfer(address(mfd), _amount);
			IMFDPlus(mfd).mint(_to, _amount, true);
			return _amount;
		}
	}

列表 3.22:BountyManager.sol

建議 即使代幣不足,也應將剩餘的 RDNT 代幣轉出。

3.3.3 潛在問題 20:requiredUsdValue() 中的命名不一致

項目 描述
狀態 已確認
引入版本 版本 1

描述 requiredUsdValue() 函數用於檢查想要透過持有 RTokens 獲得獎勵資格的使用者所需鎖定的價值。該計算基於使用者的抵押品價值,該價值由 getUserAccountData() 函數返回。然而,返回的值命名為 totalCollateralETH,這與 requiredUsdValue() 函數中(即 totalCollateralUSD)不一致。

建議 使用正確的代幣名稱來標準化函數的命名慣例。例如,將 requiredUsdValue() 更名為 requiredEthValue()。

反饋 我們傾向於讓 AAVE 合約保持盡可能相似,因此我們沒有更新名稱。

3.4 備註

3.4.1 潛在問題 21:已棄用的 MFDPlus

項目 描述
狀態 已確認
引入版本 版本 10

描述 合約 MFDPlus 不再使用。複利邏輯已移至 AutoCompounder 合約,其他邏輯已移至 MiddleFeeDistribution 合約。

4. 附錄

4.1 自動化靜態安全性測試結果

表 4.1:自動化靜態安全性測試結果。Found 表示工具報告的問題數量。FP 表示經我們人工驗證後的誤報數量。

ID 檢測器 描述 影響 Found FP 結果
1 arbitrary-send-erc20 使用任意 from 呼叫 transferFrom 1 1 通過
2 array-by-reference 按值修改儲存陣列 0 0 通過
3 incorrect-shift 移位指令中的參數順序不正確 0 0 通過
4 multiple-constructors 多個建構子方案 0 0 通過
5 name-reused 重複使用合約名稱 0 0 通過
6 protected-vars 未經存取控制直接修改變數 0 0 通過
7 rtlo 使用從右到左覆寫控制字元 0 0 通過
8 shadowing-state 狀態變數遮蔽 1 1 通過
9 suicidal 允許任何人銷毀合約的函數 0 0 通過
10 uninitialized-state 未初始化的狀態變數 3 3 通過
11 uninitialized-storage 未初始化的儲存變數 0 0 通過
12 unprotected-upgrade 未受保護的可升級合約 1 1 通過
13 arbitrary-send-erc20-permit transferFrom 在使用 permit 時使用任意 from 0 0 通過
14 arbitrary-send-eth 向任意目的地發送以太幣的函數 0 0 通過
15 controlled-array-length 受污染的陣列長度分配 0 0 通過
16 controlled-delegatecall 受控的 delegatecall 目的地 0 0 通過
17 delegatecall-loop 在迴圈中使用 delegatecall 的應付函數 0 0 通過
18 msg-value-loop 在迴圈中使用 msg.value 0 0 通過
19 reentrancy-eth 重入漏洞(以太幣盜竊) 5 5 通過
20 storage-array 有符號儲存整數陣列編譯器錯誤 0 0 通過
21 unchecked-transfer 未經檢查的代幣轉帳 12 12 通過
22 weak-prng 弱偽隨機數生成器 (PRNG) 0 0 通過
23 domain-separator-collision 檢測函數簽名與 EIP-2612 DOMAIN_SEPARATOR() 碰撞的 ERC20 代幣 0 0 通過
24 enum-conversion 檢測危險的枚舉轉換 0 0 通過
25 erc20-interface 不正確的 ERC20 介面 0 0 通過
26 erc721-interface 不正確的 ERC721 介面 0 0 通過
27 incorrect-equality 危險的嚴格相等判斷 23 23 通過
28 locked-ether 鎖定以太幣的合約 1 1 通過
29 mapping-deletion 對包含結構的映射進行刪除 0 0 通過
30 shadowing-abstract 來自抽象合約的狀態變數遮蔽 0 0 通過
31 tautology 重言式或矛盾 0 0 通過
32 write-after-write 未使用的寫入 3 3 通過
33 boolean-cst 布林常數誤用 0 0 通過
34 constant-function-asm 使用組合語言程式碼的常數函數 0 0 通過
35 constant-function-state 改變狀態的常數函數 0 0 通過
36 divide-before-multiply 不精確的算術運算順序 20 20 通過
37 reentrancy-no-eth 重入漏洞(無以太幣盜竊) 12 12 通過
38 reused-constructor 重複使用的基礎建構子 0 0 通過
39 tx-origin 危險地使用 tx.origin 1 1 通過
40 unchecked-lowlevel 未經檢查的低階呼叫 0 0 通過
41 unchecked-send 未經檢查的 send 0 0 通過
42 uninitialized-local 未初始化的區域變數 33 33 通過
43 unused-return 未使用的返回值 19 19 通過

4.2 自動化動態安全性測試結果

表 4.2:與借貸相關邏輯的測試屬性

ID 屬性 結果
1 呼叫 deposit 永遠不會導致 onBehalfOf 的 RToken 數量減少 通過
2 呼叫 withdraw 永遠不會導致 msg.sender 的 RToken 數量增加 通過
3 在穩定利率模式下呼叫 borrow 永遠不會導致 onBehalfOf 的 StableDebtToken 減少。 通過
4 在變動利率模式下呼叫 borrow 永遠不會導致 onBehalfOf 的 VariableDebtToken 減少。 通過
5 使用不等於 msg.sender 的 onBehalfOf 呼叫 borrow 永遠不會導致 msg.sender 的借貸額度增加。 通過
6 在穩定利率模式下呼叫 repay 永遠不會導致 onBehalfOf 的 StableDebtToken 增加。 通過
7 在變動利率模式下呼叫 repay 永遠不會導致 onBehalfOf 的 VariableDebtToken 增加。 通過
8 liquidityIndex 永遠不會減少。 通過
9 liquidityIndex 在同一區塊內將保持不變。 通過
10 variableBorrowIndex 永遠不會減少。 通過
11 variableBorrowIndex 在同一區塊內將保持不變。 通過
12 減少抵押品數量永遠不會導致健康係數小於 1。 通過
13 增加借貸數量永遠不會導致健康係數小於 1。 通過

表 4.3:與質押相關邏輯的測試屬性

ID 屬性 結果
1 使用者的總餘額永遠等於鎖定餘額、解鎖餘額和已賺取餘額之和。 通過
2 使用者的鎖定餘額永遠等於 userLocks 金額之和 通過
3 使用者的 lockedWithMultiplier 餘額永遠等於 userLocks 金額與使用者鎖定倍數的乘積之和 通過
4 lockedSupply 永遠等於使用者的鎖定餘額之和 通過
5 lockedSupplyWithMultiplier 永遠等於使用者的 lockedWithMultiplier 餘額之和 通過
6 rewardPerTokenStored 永遠不會減少。 通過
7 rewardPerTokenStored 在同一區塊內將保持不變。 通過
8 totalSupply 永遠等於使用者的金額之和 通過
9 accRewardPerShare 永遠不會減少。 通過
10 accRewardPerShare 在同一區塊內將保持不變。 通過

表 4.4:其他功能的測試屬性

ID 屬性 結果
1 LockedZap 合約中的 WETH 和 RDNT 餘額永遠為零。 通過
2 LiquidityZap 合約中的 WETH 和 RDNT 餘額永遠為零。 通過
3 BalancerPoolHelper 合約中的 WETH 和 RDNT 餘額永遠為零。 通過
4 UniswapPoolHelper 合約中的 WETH 和 RDNT 餘額永遠為零。 通過
5 呼叫 loop 永遠會使使用者有資格獲得獎勵 通過
6 呼叫 loopETH 永遠會使使用者有資格獲得獎勵 通過
7 使用 _execute 為 false 呼叫 executeBounty 永遠不會導致儲存變更。 通過
8 使用 sender 等於 receiver 呼叫 transfer 永遠不會導致餘額變更。 版本 1 失敗。版本 7 通過

5 注意事項與評論

5.1 免責聲明

本報告不構成投資建議或個人推薦。它不考慮且不應被解釋為考慮或與代幣、代幣銷售或任何其他產品、服務或其他資產的潛在經濟效益有關。任何實體不應以任何方式依賴本報告,包括用於做出買賣任何代幣、產品、服務或其他資產的任何決定。

本報告並非對任何特定項目或團隊的代言,且本報告不保證任何特定項目的安全性。此安全性測試不保證能發現智慧合約的所有安全問題,即評估結果不保證不存在任何進一步的安全問題。由於安全性測試不能被視為全面,我們始終建議進行獨立審計和公開漏洞賞金計劃,以確保智慧合約的安全性。

本安全性測試的範圍僅限於第 1.2 節中提到的程式碼。除非明確指定,否則語言本身的安全性(例如 Solidity 語言)、底層編譯工具鏈和計算基礎設施均不在範圍內。

5.2 審計程序

我們遵循以下程序執行審計。

  • 漏洞檢測:我們首先使用自動程式碼分析器掃描智慧合約,然後手動驗證(拒絕或確認)它們報告的問題。

  • 語義分析:我們研究智慧合約的業務邏輯,並使用自動模糊測試工具(由我們的研究團隊開發)對可能存在的漏洞進行深入調查。我們還與獨立審計員手動分析可能的攻擊路徑,以交叉檢查結果。

  • 建議:我們從良好程式設計實踐的角度為開發人員提供一些有用的建議,包括 Gas 優化、程式碼風格等。

我們在下方展示了主要具體的檢查點。

5.2.1 軟體安全

  • 重入性 (Reentrancy)

  • 拒絕服務 (DoS)

  • 存取控制

  • 資料處理與資料流程

  • 異常處理

  • 不受信任的外部呼叫與控制流程

  • 初始化一致性

  • 事件操作

  • 易錯的隨機性

  • 代理系統使用不當

5.2.2 DeFi 安全

  • 語義一致性

  • 功能一致性

  • 權限管理

  • 業務邏輯

  • 代幣操作

  • 緊急機制

  • 預言機安全

  • 白名單與黑名單

  • 經濟影響

  • 批次轉帳

5.2.3 NFT 安全

  • 重複項目

  • 代幣接收者的驗證

  • 鏈下元資料安全性

5.2.4 額外建議

  • Gas 優化

  • 程式碼品質與風格

注意:以上為主要檢查點。我們可能會根據專案的功能在審計過程中增加更多的檢查點。

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit