Back to Blog

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

Code Auditing
March 23, 2023
38 min read

レポートマニフェスト

項目 説明
クライアント 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に示します。

これに応じて、本レポートで測定される深刻度はの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における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 ミンターは一度しか割り当てられない DeFiセキュリティ 確認済み
18 - ガス最適化(MfdのzapVestingToLp()) 推奨事項 修正済み
19 - BountyManagerにおける非空のバウンティリザーブ 推奨事項 修正済み
20 - requiredUsdValue()における命名の不一致 推奨事項 確認済み
21 - 非推奨のMFDPlusに関する注記 注記 確認済み

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

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

3.1.1 潜在的問題1:関数ポインタのリセット用インターフェースが未実装

項目 説明
深刻度
ステータス バージョン7で修正済み
発見バージョン バージョン1

説明 コントラクトBountyManagerでは、getLpMfdBounty()、getChefBounty()、getAutoCompoundBounty()の3つの関数が関数ポインタを通じて呼び出されています。一方、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つの関数のオフセットが変更された場合、関数ポインタが期待通りに機能せず、コントラクトのロジック全体が変更される可能性があります。

提案 コントラクトは関数ポインタをリセットするためのインターフェースを提供すべきです。

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を超える場合、最低価格が返されます。ただし、ソースオラクルの1つから返される結果が異常に低い場合、戻り値は依然として操作される可能性があります。

/**
    * @notice 計算された価格
    * @return price 複数のソースの平均価格。
    */
   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)を固定期間ロックして報酬を獲得できます。ロックが期限切れになると、このユーザーがAutoRelockを有効にしている場合、他のユーザーはexecuteBounty()を呼び出してトークンを再ロックし、BaseBountyを獲得できます。再ロックプロセス中、期限切れのロックは内部関数_cleanWithdrawableLocks()でクリアされ、プールに再ステーキングされます。ただし、変数maxLockWithdrawPerTxnがクリアできるロックの最大数を制限しています。この場合、executeBounty()が実行された後でも、クリアされていない期限切れのロックが残る可能性があります。これにより、コントラクトMFDPlusのclaimBounty()関数の106行目のチェックをバイパスできます。issueBaseBountyがtrueに設定されて返されます。

**
    * @notice ロック解除時間が過ぎたすべてのロックトークンを引き出す
    */
   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でtokenV1からtokenV2へ交換するために実装されています。ただし、マイグレーションのプロセス中、このexchangeRateはオーナーがsetExchangeRate()関数を通じて調整できます。

/**
    * @notice V1からV2へのマイグレーション
    * @param amount V1トークンの量
    */
   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(); // 現在の累積価格値(1 / 0)を取得
        price1CumulativeLast = pair.price1CumulativeLast(); // 現在の累積価格値(0 / 1)を取得
        uint112 reserve0;
        uint112 reserve1;
        (reserve0, reserve1, blockTimestampLast) = pair.getReserves();
        require(reserve0 != 0 && reserve1 != 0, 'UniswapPairOracle: NO_RESERVES'); // ペアに流動性があることを確認

        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, // スリッページはこの関数の後で処理される
         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, // スリッページはこの関数の後で処理される
         mfdHelper.getRewardToBaseRoute(underlying),
         address(this),
         block.timestamp + 10
     );

リスト3.14: LendingPool.sol

影響 レバレッジャーが最初に設定されていない場合、攻撃者がレバレッジャーを任意のアドレスに設定し、depositWithAutoDLP()関数のロジックを制御できます。

提案 initialize()関数でレバレッジャーを設定するか、setLeverager()関数にアクセス制御を追加してください。

3.2.12 潜在的問題13:addLiquidityWETHOnly()におけるスリッページチェックなし

項目 説明
深刻度
ステータス 確認済み
発見バージョン バージョン1

説明 ユーザーはMFDコントラクトの借入WETHトークン(または自分のETHトークン)または権利確定中の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つの配列の長さが等しいかどうかのチェックがありません。

// アセットのプールIDを設定する
    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:ミンターは一度しか割り当てられない

項目 説明
深刻度
ステータス 確認済み
発見バージョン バージョン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からearnigns配列を反復し、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; ) {
            // 権利確定のみなので、現在ロックされているアイテムのみを対象とする
            if (earnings[i].unlockTime > block.timestamp) {
                zapped = zapped.add(earnings[i].amount);
                // 削除して配列サイズをシフト
                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()関数は、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は手動検証後の偽陽性の数を意味します。

ID 検出器 説明 影響 発見数 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 任意の宛先にEtherを送信する関数 0 0 合格
15 controlled-array-length 汚染された配列の長さ割り当て 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 符号付きストレージ整数配列コンパイラのバグ 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 ブール定数の誤用 0 0 合格
34 constant-function-asm アセンブリコードを使用する定数関数 0 0 合格
35 constant-function-state 状態を変更する定数関数 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: 貸付関連ロジックのテスト済みプロパティ

ID プロパティ 結果
1 depositを呼び出してもonBehalfOfのRToken量が減少しない 合格
2 withdrawを呼び出してもmsg.senderのRToken量が増加しない 合格
3 固定金利モードでborrowを呼び出してもonBehalfOfのStableDebtTokenが減少しない 合格
4 変動金利モードでborrowを呼び出してもonBehalfOfのVariableDebtTokenが減少しない 合格
5 onBehalfOfがmsg.senderと異なる状態で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の金額と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 追加推奨事項

  • ガス最適化

  • コード品質とスタイル

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

Best Security Auditor for Web3

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

BlockSec Audit