Back to Blog

Radiant V2 安全测试报告

Code Auditing
March 23, 2023
33 min read

报告清单

项目 描述
客户 Radiant Capital
目标 Radiant V2

版本历史

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

1. 简介

1.1 关于安全测试

我们受 Radiant Capital 邀请,作为红队对 Radiant V2 的智能合约进行安全测试,以识别潜在风险。作为一个负责任的团队,Radiant Capital 高度重视安全性。因此,该团队决定投入更多精力来保障这些智能合约的安全,尽管这些合约已经过多家安全公司的审计^1

请注意,安全测试在目标和要求上与安全审计不同。具体而言,安全测试旨在通过模拟攻击者来破坏程序/协议,从而发现额外的/非常规的漏洞;而安全审计旨在通过枚举可能的攻击来提供相对全面的安全检查。因此,由于时间和资源有限,安全测试可能无法覆盖一些通过安全审计才能识别的复杂逻辑漏洞。

1.2 关于目标合约

信息 描述
类型 智能合约
语言 Solidity
方法 静态分析、动态分析、半自动及人工验证

目标代码库为 Radiant_v2.1.1。安全测试期间的提交 SHA 值如下所示。本报告仅负责初始版本(即版本 1)以及修复报告中问题的新代码。

请注意,本报告仅涵盖该代码库 radiant_v2.1.1/contracts 文件夹下的智能合约,包括:

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

在版本 8 更新后,本次安全测试涵盖的文件包括:

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

1.3 安全模型

为评估风险,我们遵循业界和学术界广泛采用的标准或建议,包括 OWASP 风险评级方法论^2和通用弱点枚举^3。风险的总体严重程度可能性影响决定。具体而言,可能性用于估计特定漏洞被攻击者发现和利用的可能性,而影响用于衡量成功利用漏洞所带来的后果。

在本报告中,可能性和影响均分为两个等级,即分别为,其组合如表 1.1 所示。

相应地,本报告中衡量的严重程度分为三类:。为完整起见,当风险无法被明确确定时,也使用未确定来涵盖相关情况。

此外,已发现项目的状态将归入以下四类之一:

  • 未确定 尚无回应。

  • 已知悉 客户已收到该项目,但尚未确认。

  • 已确认 客户已认可该项目,但尚未修复。

  • 已修复 客户已确认并修复该项目。

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() 中缺少铸币权限撤销 DeFi 安全 已确认
17 铸币者只能被分配一次 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 计算价格
    * @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 返回的价格可以被操控,从而使攻击者从中获利。

建议 我们建议使用中位数而非平均值。如果只有两个来源预言机且出现较大差异,当平均价格远大于最低价格时,更合理的做法是回滚交易。

反馈 将只有两个来源预言机。如果出现较大差异,我们将使用 OZ Defender Sentinel 来暂停相关合约。

说明 合约 ComboOracle 已被移除,不再使用。

3.2.2 潜在问题 3:通过 BaseBounty 潜在的资金流失

项目 描述
严重程度
状态 已在版本 4 中修复
引入版本 版本 1

描述 用户可以将代币(即 RDNT)锁定固定期限以获取奖励。当锁定到期后,其他用户可以调用函数 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 可被攻击者耗尽。

影响 攻击者可以在一笔交易中耗尽合约 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 为用户以指定的兑换率将 tokenV1 兑换为 tokenV2 而实现。然而,在迁移过程中,所有者仍可通过函数 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

影响 如果在迁移过程中更改兑换率,对其他用户将不公平。

建议 一旦迁移开始,兑换率应固定不变。

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

影响 如果 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。然而,未检查这两个数组的长度是否相等。

// 设置资产的池子 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() 中缺少铸币权限撤销

项目 描述
严重程度
状态 已确认
引入版本 版本 1

描述 函数 addBountyContract() 用于设置新的 BountyManager。然而,原始赏金合约仍保留铸币权限,这与原始设计不符。

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

代码清单 3.19:Leverager.sol

影响 已弃用的 BountyManager 仍具有铸币权限。

建议 撤销原始 BountyManager 合约的铸币权限。

反馈 函数 addBountyContract 只会被调用一次以初始化 BountyManager。

3.2.16 潜在问题 17:铸币者只能被分配一次

项目 描述
严重程度
状态 已确认
引入版本 版本 1

描述 minters 用于记录那些有权访问函数 mint() 和 addReward() 的人。然而,当其中一个铸币者(例如合约 ChefIncentivesController)被更新时,过时的铸币者无法被移除。

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

代码清单 3.20:MultiFeeDistribution.sol

影响 过时的铸币者在升级时无法被移除。

建议 实现一个特权函数来修改铸币者。

反馈 由于 BountyManager、ChefIncentivesController 和 MultiFeeDistribution 将是可升级的,因此铸币者始终保持相同的代理地址。

3.3 附加建议

3.3.1 潜在问题 18:Gas 优化(Mfd 中的 zapVestingToLp())

项目 描述
状态 已在版本 10 中修复
引入版本 版本 1

描述 函数 zapVestingToLp() 只能由合约 LockZap 调用以转出用户的锁定收益。它从索引 0 开始遍历用户的收益数组,并检查 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

建议 从收益数组末尾向开头开始迭代。如果 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:自动化静态安全测试结果。发现数表示工具报告的问题数量。误报数表示经人工验证后的误报数量。

编号 检测器 描述 影响 发现数 误报数 结果
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 未经检查的 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 当 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:质押相关逻辑的测试属性

编号 属性 结果
1 用户的总余额始终等于锁定余额、未锁定余额和已获得余额之和 通过
2 用户的锁定余额始终等于 userLocks 数量之和 通过
3 用户的带乘数锁定余额始终等于 userLocks 数量乘以 userLocks 乘数之和 通过
4 lockedSupply 始终等于用户锁定余额之和 通过
5 lockedSupplyWithMultiplier 始终等于用户带乘数锁定余额之和 通过
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 优化

  • 代码质量和风格

注:上述检查点为主要检查点。在审计过程中,我们可能会根据项目的功能使用更多检查点。

Best Security Auditor for Web3

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

BlockSec Audit