Back to Blog

Radiant V2 安全測試報告

Code Auditing
March 23, 2023
32 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 所示。

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

此外,已發現項目的狀態將分為以下四個類別之一:

  • 未確定 尚無回應。

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

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

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

2. 自動化安全測試

2.1 自動化靜態安全測試

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

2.2 自動化動態安全測試

我們利用模糊測試技術來測試目標合約的穩健性、可靠性和精確性。具體而言,模糊測試過程中的初始種子是根據函數語義和合約測試腳本確定的。為了模擬鏈上環境,我們還維護了一組與合約 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

編號 嚴重性 描述 類別 狀態
1 未預留重置函數指針的介面 軟體安全 已修復
2 預言機計算不當 DeFi 安全 已修復
3 透過 BaseBounty 潛在的資金耗盡 DeFi 安全 已修復
4 潛在的無效排放排程 DeFi 安全 已修復
5 可跳過的排放排程 DeFi 安全 已確認
6 遷移過程中可變更的兌換率 DeFi 安全 已修復
7 _transfer() 實作不當 (I) DeFi 安全 已修復
8 UniV2TwapOracle 中缺少對 Period 的檢查 DeFi 安全 已修復
9 不可退還的零散代幣 DeFi 安全 已修復
10 _transfer() 實作不當 (II) DeFi 安全 已修復
11 可操控的複利獎勵 DeFi 安全 已修復
12 setLeverager() 缺少存取控制 DeFi 安全 已修復
13 addLiquidityWETHOnly() 中沒有滑點檢查 DeFi 安全 已確認
14 loopETH() 中缺少對 borrowRatio 的檢查 DeFi 安全 已修復
15 setPoolIDs() 中缺少對 assets 和 poolIDs 長度的檢查 DeFi 安全 已修復
16 addBountyContract() 中缺少撤銷 mint 權限 DeFi 安全 已確認
17 Minters 只能被分配一次 DeFi 安全 已確認
18 - Gas 優化(Mfd 中的 zapVestingToLp()) 建議 已修復
19 - BountyManager 中非空的 Bounty 儲備 建議 已修復
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)鎖定一段固定時間以獲得獎勵。當鎖定到期後,其他用戶可以調用函數 executeBounty() 為此用戶重新鎖定代幣,以賺取 BaseBounty(如果此用戶已啟用 AutoRelock)。在重新鎖定過程中,已到期的鎖定將通過內部函數 _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 的功能。然而,在遷移過程中,所有者仍可通過函數 setExchangeRate() 調整此 exchangeRate。

/**
    * @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

影響 如果在遷移過程中更改 exchangeRate,對其他用戶將不公平。

建議 一旦遷移開始,exchangeRate 應固定不變。

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:OpenZeppelin 中的 ERC20.sol

3.2.7 潛在問題 8:UniV2TwapOracle 中缺少對 Period 的檢查

項目 描述
嚴重性
狀態 已在版本 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 代幣。在此過程中,可能存在應退還給用戶的零散代幣。然而,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() 將調用函數 handle_ActionAfter() 來相應地更新合約 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

影響 攻擊者可以搶先執行交易以操控價格並獲利。

建議 在函數 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() 中缺少對 assets 和 poolIDs 長度的檢查

項目 描述
嚴重性
狀態 已在版本 10 中修復
引入版本 版本 1

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

// 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

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

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

3.2.15 潛在問題 16:addBountyContract() 中缺少撤銷 mint 權限

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

描述 函數 addBountyContract() 用於設置新的 BountyManager。然而,原始賞金合約仍持有 mint 權限,這與原始設計相違背。

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

列表 3.19:Leverager.sol

影響 已棄用的 BountyManager 仍具有 mint 權限。

建議 撤銷原始 BountyManager 合約的 mint 權限。

回饋 函數 addBountyContract 只會被調用一次以初始化 BountyManager。

3.2.16 潛在問題 17:Minters 只能被分配一次

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

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

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

影響 當過時的 minter 升級時無法被移除。

建議 實作一個特權函數來修改 minters。

回饋 由於 BountyManager、ChefIncentivesController 和 MultiFeeDistribution 將可升級,因此 minters 始終保持相同的代理地址。

3.3 其他建議

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

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

描述 函數 zapVestingToLp() 只能由合約 LockZap 調用以轉出用戶的鎖定收益。它從索引 0 開始遍歷用戶的收益陣列,並檢查 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

建議 從收益陣列末尾向開頭開始迭代。如果 unlockTime 小於當前時間戳,則可以中斷循環。

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

項目 描述
狀態 已在版本 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() 用於檢查想要通過持有 RToken 獲得資格賺取獎勵的用戶所需的鎖定價值。計算基於用戶的抵押品價值,該值從函數 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 表示經過人工驗證後的誤報數量。

編號 檢測器 描述 影響 發現 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 弱偽隨機數生成器 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 未檢查的發送 0 0 通過
42 uninitialized-local 未初始化的局部變量 33 33 通過
43 unused-return 未使用的返回值 19 19 通過

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

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

編號 屬性 結果
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:質押相關邏輯的測試屬性

編號 屬性 結果
1 用戶的總餘額始終等於鎖定餘額、解鎖餘額和已賺取餘額的總和 通過
2 用戶的鎖定餘額始終等於 userLocks 數量的總和 通過
3 用戶的 lockedWithMultiplier 餘額始終等於 userLocks 數量乘以 userLocks 乘數的總和 通過
4 lockedSupply 始終等於用戶鎖定餘額的總和 通過
5 lockedSupplyWithMultiplier 始終等於用戶 lockedWithMultiplier 餘額的總和 通過
6 rewardPerTokenStored 永遠不會減少 通過
7 rewardPerTokenStored 在同一區塊內將保持不變 通過
8 totalSupply 始終等於用戶數量的總和 通過
9 accRewardPerShare 永遠不會減少 通過
10 accRewardPerShare 在同一區塊內將保持不變 通過

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

編號 屬性 結果
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 以發送者等於接收者調用 transfer 永遠不會導致餘額變化 版本 1 失敗,版本 7 通過

5 聲明與備註

5.1 免責聲明

本報告不構成投資建議或個人推薦。本報告不考慮代幣、代幣銷售或任何其他產品、服務或其他資產的潛在經濟影響,也不應被解釋為對此有任何影響。任何主體不應以任何方式依賴本報告,包括以此為目的做出買賣任何代幣、產品、服務或其他資產的決定。

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

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

5.2 審計程序

我們按照以下程序進行審計。

  • 漏洞檢測 我們首先使用自動代碼分析器掃描智能合約,然後人工驗證(拒絕或確認)這些工具報告的問題。

  • 語義分析 我們研究智能合約的業務邏輯,並使用自動模糊測試工具(由我們的研究團隊開發)對可能的漏洞進行進一步調查。我們也與獨立審計員手動分析可能的攻擊場景以交叉核對結果。

  • 建議 我們從良好編程實踐的角度向開發者提供一些有用的建議,包括 Gas 優化、代碼風格等。

我們在以下內容中展示主要的具體檢查點。

5.2.1 軟體安全

  • 重入攻擊

  • 拒絕服務攻擊

  • 存取控制

  • 數據處理和數據流

  • 異常處理

  • 不受信任的外部調用和控制流

  • 初始化一致性

  • 事件操作

  • 易出錯的隨機性

  • 代理系統的不當使用

5.2.2 DeFi 安全

  • 語義一致性

  • 功能一致性

  • 權限管理

  • 業務邏輯

  • 代幣操作

  • 緊急機制

  • 預言機安全

  • 白名單和黑名單

  • 經濟影響

  • 批量轉帳

5.2.3 NFT 安全

  • 重複項目

  • 代幣接收者驗證

  • 鏈下元數據安全

5.2.4 其他建議

  • Gas 優化

  • 代碼品質和風格

備註:上述檢查點為主要檢查點。我們可能會根據項目的功能在審計過程中使用更多檢查點。

Sign up for the latest updates
~$598萬損失:Aztec、Raydium等|BlockSec週報
Security Insights

~$598萬損失:Aztec、Raydium等|BlockSec週報

本週區塊鏈安全報告涵蓋2026年6月8日至15日,分析以太坊和Solana上4起重大事件,總損失約598萬美元。重點事件包括:Aztec Connect因缺少輸入驗證導致rollup證明路徑與L1結算狀態不一致;Raydium因舊版AMM v3程式缺少驗證,攻擊者操縱LP代幣贖回計算並清空四個池。兩個漏洞均存在多年後才被利用。報告涵蓋輸入驗證缺失、整數溢出及治理攻擊等類型。

Zcash Orchard 健全性漏洞分析 | BlockSec 週報
Security Insights

Zcash Orchard 健全性漏洞分析 | BlockSec 週報

2026年6月1日當週,Zcash Orchard隱私池電路被公開披露存在嚴重健全性漏洞。該漏洞由halo2 ECC標量乘法組件缺少等式約束引起,可能導致透過雙重支付在Orchard池中無法被偵測地偽造ZEC。此漏洞自2022年5月Orchard啟用以來已存在逾四年,由研究員Taylor Hornby使用Anthropic Opus 4.8模型進行AI輔助安全審計時發現,並透過緊急網路升級(NU6.2)修補。本報告涵蓋技術根本原因、AI輔助發現過程、緊急應對時間軸及對ZKP生態系統的影響。

通訊 - 2026年5月
Security Insights

通訊 - 2026年5月

2026年5月,DeFi生態發生三起重大安全事件。Echo Protocol因管理員密鑰外洩遭惡意增發eBTC,損失約7,670萬美元;StablR因多簽治理漏洞被非法發行穩定幣,損失約1,280萬美元;Verus-Ethereum Bridge因類型驗證失敗導致攻擊,損失約1,170萬美元。

Best Security Auditor for Web3

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

BlockSec Audit