Radiant V2 セキュリティテストレポート

これは、2023年3月に実施したRadiant V2のセキュリティテストレポートです。

Radiant V2 セキュリティテストレポート

レポートマニフェスト

項目 説明
クライアント Radiant Capital
ターゲット Radiant V2

バージョン履歴

バージョン 日付 説明
1.0 2023年3月15日 初版
2.0 2023年3月21日 第2版

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を含む、業界および学術界で広く採用されている標準または提案に従います。リスクの全体的な深刻度は、可能性影響によって決定されます。具体的には、可能性は、特定の脆弱性が攻撃者によって発見され悪用される可能性を推定するために使用され、影響は、成功した悪用の結果を測定するために使用されます。

本レポートでは、可能性と影響の両方をそれぞれの2つの評価に分類し、それらの組み合わせを表1.1に示します。

表1.1: 脆弱性深刻度分類

したがって、本レポートで測定された深刻度は、の3つのカテゴリに分類されます。完全性のために、リスクを適切に判断できない状況をカバーするために、未定も使用されます。

さらに、発見された項目のステータスは、以下の4つのカテゴリのいずれかに分類されます。

  • 未定 まだ返信がありません。

  • 認識済み 項目はクライアントに受信されましたが、まだ確認されていません。

  • 確認済み 項目はクライアントによって認識されましたが、まだ修正されていません。

  • 修正済み 項目はクライアントによって確認され、修正されました。

2. 自動セキュリティテスト

2.1 自動静的セキュリティテスト

Slitherに基づいた社内静的解析ツールを使用して、脆弱性の存在を確認します。結果の手動確認後、問題は見つかりませんでした。詳細なテスト結果は、付録の表4.1に記載されています。

2.2 自動動的セキュリティテスト

ファジング手法を利用して、対象コントラクトの堅牢性、信頼性、および精度をテストします。具体的には、ファジングプロセスの初期シードは、関数のセマンティクスとコントラクトテストスクリプトに基づいて決定されます。オンチェーン環境をシミュレートするために、コントラクトLendingPoolおよびMultiFeeDistributionとやり取りしたアドレスのセットも維持します。

ファザーは、トランザクションシーケンス生成中に、関数のセマンティクスも考慮します。たとえば、コントラクトMultiFeeDistributionの関数stakeやコントラクトLendingPoolの関数depositは、シーケンスで最初に呼び出される可能性が高いです。関数のパラメータとシーケンスの変更は、コントラクトコードカバレッジによってガイドされます。特定のパラメータまたはシーケンスが高いコードカバレッジに達した場合、次のファジングラウンドで変更される優先度が高くなります。マジックナンバーによって制約されるパスを探索するために、実行時にストレージから読み取られた値(すなわちSLOAD命令)を収集し、変更プロセス中に、関数のパラメータを生成するために使用します。

合計で、100,000のテストケースを生成し、31のオラクルを使用します。これは、障害が発生したかどうかを検出するために使用されます。各テストケースには、指定された順序で30のトランザクションが含まれています。最終的に、1つのクリティカルな問題(すなわち、セクション3.2.6)を発見しました。これは、手動セキュリティテストプロセスでも発見されました。詳細なテスト結果は、付録の表4.2、4.3、および4.4に記載されています。

3. 手動セキュリティテスト

手動の労力を投入して全体的な設計とモジュール間の相互作用を理解し、以前の研究と経験から導き出された潜在的な攻撃サーフェスに関するノウハウに基づいてセキュリティテストを実施します。

合計で、17件の潜在的な問題を発見しました。さらに、3件の推奨事項と1件の注意事項があります。

  • 高リスク: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 返金不可のダストトークン DeFiセキュリティ 修正済み
10 _transfer()の不適切な実装 (II) DeFiセキュリティ 修正済み
11 操作可能な化合物報酬 DeFiセキュリティ 修正済み
12 setLeverager()でのアクセス制御の欠如 DeFiセキュリティ 修正済み
13 addLiquidityWETHOnly()でのスリッページチェックなし DeFiセキュリティ 確認済み
14 loopETH()でのborrowRatioチェックの欠如 DeFiセキュリティ 修正済み
15 setPoolIDs()でのassetsとpoolIDs間の長さチェックの欠如 DeFiセキュリティ 修正済み
16 addBountyContract()でのミント権限剥奪の欠如 DeFiセキュリティ 確認済み
17 ミンターは一度だけ割り当て可能 DeFiセキュリティ 確認済み
18 - ガス最適化 (MfdのzapVestingToLp()) 推奨事項 修正済み
19 - BountyManagerの空でない賞金準備金 推奨事項 修正済み
20 - requiredUsdValue()での不整合な命名 推奨事項 確認済み
21 - MFDPlusの廃止された注意事項 注意事項 確認済み

詳細は以下のセクションで提供します。

3.1 ソフトウェアセキュリティ

3.1.1 潜在的な問題1:関数ポインタのリセットのための予約済みインターフェースなし

項目 説明
重大度
ステータス バージョン7で修正済み
導入元 バージョン1

説明 BountyManagerコントラクトでは、getLpMfdBounty()、getChefBounty()、getAutoCompoundBounty()の3つの関数が関数ポインタを介して呼び出されます。同時に、Ownable-Upgradableからの継承は、このコントラクトがプロキシの実装であることを示しています。これは、実装コントラクトが将来アップグレードされる可能性があり、関数ポインタに関連する問題が発生することを示唆しています。

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つの関数のオフセットが変更されると、関数ポインタは期待どおりに機能せず、コントラクト全体のロジックが変更される可能性があります。

提案 コントラクトには、関数ポインタをリセットするためのインターフェースを提供する必要があります。

3.2 DeFiセキュリティ

3.2.1 潜在的な問題2:オラクルの不適切な計算

項目 説明
重大度
ステータス バージョン11で修正済み
導入元 バージョン1 および バージョン4

説明 ComboOracleコントラクトのconsult()関数は、複数のソースから平均価格を計算するために使用されます。バージョン1の実装では、算術平均を使用して最終価格を計算しますが、これは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からの戻り価格は操作可能であり、攻撃者がそれを利用して利益を得ることができます。

提案 平均値ではなく中央値を使用することをお勧めします。ソースオラクルが2つだけで、かなりの差がある場合、平均価格が最低価格よりかなり大きい場合にトランザクションをロールバックするのがより合理的です。

フィードバック ソースオラクルは2つだけになります。かなりの差がある場合は、関連するコントラクトを一時停止するためにOZ Defender Sentinelを使用します。

注意 コントラクトComboOracleは削除され、使用されなくなりました。

3.2.2 潜在的な問題3:BaseBountyを介した潜在的な資金流出

項目 説明
重大度
ステータス バージョン4で修正済み
導入元 バージョン1

説明 ユーザーはトークン(RDNT)を固定期間ロックして報酬を得ることができます。ロックが期限切れになると、他のユーザーはexecuteBounty()関数を呼び出して、このユーザーがAutoRelockを有効にしている場合に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は攻撃者によって排出される可能性があります。

影響 攻撃者は1回のトランザクションで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でトークンV1からトークンV2に交換するために実装されています。しかし、移行プロセス中、この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

影響 移行プロセス中に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: ERC20.sol in OpenZeppelin

3.2.7 潜在的な問題8:UniV2TwapOracleでの期間チェックの欠如

項目 説明
重大度
ステータス バージョン9で修正済み
導入元 バージョン1

説明 UniV2TwapOracleコントラクトでは、initialize()関数およびsetPeriod()関数で_period属性が検証されません。

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トークンに変換するのを支援するように設計されています。LPトークンを追加するためにプールに流動性を追加するためにaddLiquidityWETHOnly()関数を呼び出します。このプロセスでは、ユーザーに返金されるべきダストトークンが存在する可能性があります。しかし、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を設定できるようにします。しかし、これらの2つの配列の長さは等しいかどうかのチェックがありません。

// 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()でのミント権限剥奪の欠如

項目 説明
重大度
ステータス 確認済み
導入元 バージョン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()関数にアクセスする権限を持つものを記録するために使用されます。しかし、ミンターの1つ(例: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:ガス最適化 (MfdのzapVestingToLp())

項目 説明
ステータス バージョン10で修正済み
導入元 バージョン1

説明 zapVestingToLp()関数はLockZapコントラクトからのみ呼び出され、ユーザーのロックされた収益を転送します。ユーザーの収益配列をインデックス0から開始して反復処理し、アンロック時間が現在のタイムスタンプより大きいかどうかをチェックします。その場合、この収益は配列から削除され、転送されます。しかし、配列内のアンロック時間はインデックスとともに増加するため、配列の末尾から先頭に向かって反復処理する方が効率的です。アンロック時間が現在のタイムスタンプより小さい場合、ループを中断できます。

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

提案 収益配列の末尾から先頭に向かって反復処理を開始してください。アンロック時間が現在のタイムスタンプより小さい場合、ループを中断できます。

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 検知器 説明 影響 発見 FP 結果
1 arbitrary-send-erc20 transferFromを任意の送信元で呼び出す 1 1 合格
2 array-by-reference 値によるストレージ配列の変更 0 0 合格
3 incorrect-shift 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で任意の送信元を使用 0 0 合格
14 arbitrary-send-eth 任意の宛先にEtherを送信する関数 0 0 合格
15 controlled-array-length tainted配列の長さ代入 0 0 合格
16 controlled-delegatecall 制御されたdelegatecall宛先 0 0 合格
17 delegatecall-loop ループ内のdelegatecallを使用するpayable関数 0 0 合格
18 msg-value-loop ループ内のmsg.valueの使用 0 0 合格
19 reentrancy-eth 再入可能脆弱性(Etherの窃盗) 5 5 合格
20 storage-array signedストレージ整数配列コンパイラバグ 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 危険なenum変換を検出 0 0 合格
25 erc20-interface 不正なERC20インターフェース 0 0 合格
26 erc721-interface 不正なERC721インターフェース 0 0 合格
27 incorrect-equality 危険な厳密な等価性 23 23 合格
28 locked-ether 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 Boolean定数の誤用 0 0 合格
34 constant-function-asm アセンブリコードを使用するconstant関数 0 0 合格
35 constant-function-state ステートを変更するconstant関数 0 0 合格
36 divide-before-multiply 不正確な算術演算順序 20 20 合格
37 reentrancy-no-eth 再入可能脆弱性(Etherの窃盗なし) 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: Lending関連ロジックのテストプロパティ

ID プロパティ 結果
1 depositの呼び出しは、onBehalfOfのRToken量の減少をもたらさない 合格
2 withdrawの呼び出しは、msg.senderのRToken量の増加をもたらさない 合格
3 stable interest rate modeでのborrowの呼び出しは、onBehalfOfのStableDebtTokenの減少をもたらさない 合格
4 variable interest rate modeでのborrowの呼び出しは、onBehalfOfのVariableDebtTokenの減少をもたらさない 合格
5 onBehalfOfがmsg.senderと等しくない場合のborrowの呼び出しは、msg.senderの借入限度額の増加をもたらさない。 合格
6 stable interest rate modeでのrepayの呼び出しは、onBehalfOfのStableDebtTokenの増加をもたらさない 合格
7 variable interest rate modeでのrepayの呼び出しは、onBehalfOfのVariableDebtTokenの増加をもたらさない 合格
8 liquidityIndexは決して減少しない。 合格
9 liquidityIndexは同じブロック内で一定に保たれる。 合格
10 variableBorrowIndexは決して減少しない。 合格
11 variableBorrowIndexは同じブロック内で一定に保たれる。 合格
12 コラテラル量の減少は、ヘルスファクターを1未満にすることはない。 合格
13 借入量の増加は、ヘルスファクターを1未満にすることはない。 合格

表4.3: Staking関連ロジックのテストプロパティ

ID プロパティ 結果
1 ユーザーの総残高は、ロック残高、アンロック残高、および獲得残高の合計と常に等しい。 合格
2 ユーザーのロック残高は、userLocksの残高の合計と常に等しい 合格
3 ユーザーのlockedWithMultiplier残高は、userLocksの残高と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 送信者が受信者と等しい場合のtransferの呼び出しは、残高の変更をもたらさない。 バージョン1で失敗。バージョン7で合格

5. 通知と備考

5.1 免責事項

本レポートは、投資アドバイスまたは個人的な推奨を構成するものではありません。トークン、トークンセール、またはその他の製品、サービス、またはその他の資産の潜在的な経済性を考慮しておらず、考慮されたものとして解釈されるべきではありません。いかなるエンティティも、このレポートに、トークン、製品、サービス、またはその他の資産の売買の決定を行う目的を含め、いかなる方法でも依存すべきではありません。

本レポートは、特定のプロジェクトまたはチームの推奨ではなく、プロジェクトのセキュリティを保証するものではありません。このセキュリティテストでは、スマートコントラクトのすべてのセキュリティ問題を検出するいかなる保証も提供しません。つまり、評価結果は、さらなるセキュリティ問題の発見がないことを保証するものではありません。セキュリティテストは包括的とは見なされないため、スマートコントラクトのセキュリティを確保するために、独立した監査と公開バグバウンティプログラムの実施を常に推奨します。

このセキュリティテストの範囲は、セクション1.2で言及されているコードに限定されます。明示的に指定されていない限り、言語自体のセキュリティ(例:solidity言語)、基盤となるコンパイルツールのチェーン、およびコンピューティングインフラストラクチャは範囲外です。

5.2 監査手順

以下の手順に従って監査を実施します。

  • 脆弱性検出まず、自動コードアナライザーでスマートコントラクトをスキャンし、その後、それらが報告した問題を、手動で検証(拒否または確認)します。

  • 意味解析スマートコントラクトのビジネスロジックを研究し、自動ファジングツール(当社の研究チームによって開発)を使用して、潜在的な脆弱性についてさらに調査します。また、独立した監査担当者と協力して、可能な攻撃シナリオを手動で分析し、結果を相互確認します。

  • 推奨事項ガス最適化、コードスタイルなど、優れたプログラミングプラクティスの観点から、開発者に役立つアドバイスを提供します。

以下に、主な具体的なチェックポイントを示します。

5.2.1 ソフトウェアセキュリティ

  • 再入可能性

  • DoS

  • アクセス制御

  • データ処理とデータフロー

  • 例外処理

  • 信頼できない外部コールと制御フロー

  • 初期化の一貫性

  • イベント操作

  • エラーを起こしやすいランダム性

  • プロキシシステムの不適切な使用

5.2.2 DeFiセキュリティ

  • 意味の一貫性

  • 機能の一貫性

  • 権限管理

  • ビジネスロジック

  • トークン操作

  • 緊急メカニズム

  • オラクルセキュリティ

  • ホワイトリストとブラックリスト

  • 経済的影響

  • バッチ転送

5.2.3 NFTセキュリティ

  • 重複アイテム

  • トークン受信者の検証

  • オフチェーンメタデータセキュリティ

5.2.4 追加の推奨事項

  • ガス最適化

  • コード品質とスタイル

注意:上記のチェックポイントが主なものです。プロジェクトの機能に応じて、監査プロセス中にさらに多くのチェックポイントを使用する場合があります。

Sign up for the latest updates